Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
63 kB · 1817 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818import { feature } from 'bun:bundle'import { randomBytes } from 'crypto'import { unwatchFile, watchFile } from 'fs'import memoize from 'lodash-es/memoize.js'import pickBy from 'lodash-es/pickBy.js'import { basename, dirname, join, resolve } from 'path'import { getOriginalCwd, getSessionTrustAccepted } from '../bootstrap/state.js'import { getAutoMemEntrypoint } from '../memdir/paths.js'import { logEvent } from '../services/analytics/index.js'import type { McpServerConfig } from '../services/mcp/types.js'import type { BillingType, ReferralEligibilityResponse,} from '../services/oauth/types.js'import { getCwd } from '../utils/cwd.js'import { registerCleanup } from './cleanupRegistry.js'import { logForDebugging } from './debug.js'import { logForDiagnosticsNoPII } from './diagLogs.js'import { getGlobalClaudeFile } from './env.js'import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'import { ConfigParseError, getErrnoCode } from './errors.js'import { writeFileSyncAndFlush_DEPRECATED } from './file.js'import { getFsImplementation } from './fsOperations.js'import { findCanonicalGitRoot } from './git.js'import { safeParseJSON } from './json.js'import { stripBOM } from './jsonRead.js'import * as lockfile from './lockfile.js'import { logError } from './log.js'import type { MemoryType } from './memory/types.js'import { normalizePathForConfigKey } from './path.js'import { getEssentialTrafficOnlyReason } from './privacyLevel.js'import { getManagedFilePath } from './settings/managedPath.js'import type { ThemeSetting } from './theme.js'
/* eslint-disable @typescript-eslint/no-require-imports */const teamMemPaths = feature('TEAMMEM') ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js')) : nullconst ccrAutoConnect = feature('CCR_AUTO_CONNECT') ? (require('../bridge/bridgeEnabled.js') as typeof import('../bridge/bridgeEnabled.js')) : null
/* eslint-enable @typescript-eslint/no-require-imports */import type { ImageDimensions } from './imageResizer.js'import type { ModelOption } from './model/modelOptions.js'import { jsonParse, jsonStringify } from './slowOperations.js'
// Re-entrancy guard: prevents getConfig → logEvent → getGlobalConfig → getConfig// infinite recursion when the config file is corrupted. logEvent's sampling check// reads GrowthBook features from the global config, which calls getConfig again.let insideGetConfig = false
// Image dimension info for coordinate mapping (only set when image was resized)export type PastedContent = { id: number // Sequential numeric ID type: 'text' | 'image' content: string mediaType?: string // e.g., 'image/png', 'image/jpeg' filename?: string // Display name for images in attachment slot dimensions?: ImageDimensions sourcePath?: string // Original file path for images dragged onto the terminal}
export interface SerializedStructuredHistoryEntry { display: string pastedContents?: Record<number, PastedContent> pastedText?: string}export interface HistoryEntry { display: string pastedContents: Record<number, PastedContent>}
export type ReleaseChannel = 'stable' | 'latest'
export type ProjectConfig = { allowedTools: string[] mcpContextUris: string[] mcpServers?: Record<string, McpServerConfig> lastAPIDuration?: number lastAPIDurationWithoutRetries?: number lastToolDuration?: number lastCost?: number lastDuration?: number lastLinesAdded?: number lastLinesRemoved?: number lastTotalInputTokens?: number lastTotalOutputTokens?: number lastTotalCacheCreationInputTokens?: number lastTotalCacheReadInputTokens?: number lastTotalWebSearchRequests?: number lastFpsAverage?: number lastFpsLow1Pct?: number lastSessionId?: string lastModelUsage?: Record< string, { inputTokens: number outputTokens: number cacheReadInputTokens: number cacheCreationInputTokens: number webSearchRequests: number costUSD: number } > lastSessionMetrics?: Record<string, number> exampleFiles?: string[] exampleFilesGeneratedAt?: number
// Trust dialog settings hasTrustDialogAccepted?: boolean
hasCompletedProjectOnboarding?: boolean projectOnboardingSeenCount: number hasClaudeMdExternalIncludesApproved?: boolean hasClaudeMdExternalIncludesWarningShown?: boolean // MCP server approval fields - migrated to settings but kept for backward compatibility enabledMcpjsonServers?: string[] disabledMcpjsonServers?: string[] enableAllProjectMcpServers?: boolean // List of disabled MCP servers (all scopes) - used for enable/disable toggle disabledMcpServers?: string[] // Opt-in list for built-in MCP servers that default to disabled enabledMcpServers?: string[] // Worktree session management activeWorktreeSession?: { originalCwd: string worktreePath: string worktreeName: string originalBranch?: string sessionId: string hookBased?: boolean } /** Spawn mode for `claude remote-control` multi-session. Set by first-run dialog or `w` toggle. */ remoteControlSpawnMode?: 'same-dir' | 'worktree'}
const DEFAULT_PROJECT_CONFIG: ProjectConfig = { allowedTools: [], mcpContextUris: [], mcpServers: {}, enabledMcpjsonServers: [], disabledMcpjsonServers: [], hasTrustDialogAccepted: false, projectOnboardingSeenCount: 0, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: false,}
export type InstallMethod = 'local' | 'native' | 'global' | 'unknown'
export { EDITOR_MODES, NOTIFICATION_CHANNELS,} from './configConstants.js'
import type { EDITOR_MODES, NOTIFICATION_CHANNELS } from './configConstants.js'
export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number]
export type AccountInfo = { accountUuid: string emailAddress: string organizationUuid?: string organizationName?: string | null // added 4/23/2025, not populated for existing users organizationRole?: string | null workspaceRole?: string | null // Populated by /api/oauth/profile displayName?: string hasExtraUsageEnabled?: boolean billingType?: BillingType | null accountCreatedAt?: string subscriptionCreatedAt?: string}
// TODO: 'emacs' is kept for backward compatibility - remove after a few releasesexport type EditorMode = 'emacs' | (typeof EDITOR_MODES)[number]
export type DiffTool = 'terminal' | 'auto'
export type OutputStyle = string
export type GlobalConfig = { /** * @deprecated Use settings.apiKeyHelper instead. */ apiKeyHelper?: string projects?: Record<string, ProjectConfig> numStartups: number installMethod?: InstallMethod autoUpdates?: boolean // Flag to distinguish protection-based disabling from user preference autoUpdatesProtectedForNative?: boolean // Session count when Doctor was last shown doctorShownAtSession?: number userID?: string theme: ThemeSetting hasCompletedOnboarding?: boolean // Tracks the last version that reset onboarding, used with MIN_VERSION_REQUIRING_ONBOARDING_RESET lastOnboardingVersion?: string // Tracks the last version for which release notes were seen, used for managing release notes lastReleaseNotesSeen?: string // Timestamp when changelog was last fetched (content stored in ~/.claude/cache/changelog.md) changelogLastFetched?: number // @deprecated - Migrated to ~/.claude/cache/changelog.md. Keep for migration support. cachedChangelog?: string mcpServers?: Record<string, McpServerConfig> // claude.ai MCP connectors that have successfully connected at least once. // Used to gate "connector unavailable" / "needs auth" startup notifications: // a connector the user has actually used is worth flagging when it breaks, // but an org-configured connector that's been needs-auth since day one is // something the user has demonstrably ignored and shouldn't nag about. claudeAiMcpEverConnected?: string[] preferredNotifChannel: NotificationChannel /** * @deprecated. Use the Notification hook instead (docs/hooks.md). */ customNotifyCommand?: string verbose: boolean customApiKeyResponses?: { approved?: string[] rejected?: string[] } primaryApiKey?: string // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename) hasAcknowledgedCostThreshold?: boolean hasSeenUndercoverAutoNotice?: boolean // ant-only: whether the one-time auto-undercover explainer has been shown hasSeenUltraplanTerms?: boolean // ant-only: whether the one-time CCR terms notice has been shown in the ultraplan launch dialog hasResetAutoModeOptInForDefaultOffer?: boolean // ant-only: one-shot migration guard, re-prompts churned auto-mode users oauthAccount?: AccountInfo iterm2KeyBindingInstalled?: boolean // Legacy - keeping for backward compatibility editorMode?: EditorMode bypassPermissionsModeAccepted?: boolean hasUsedBackslashReturn?: boolean autoCompactEnabled: boolean // Controls whether auto-compact is enabled showTurnDuration: boolean // Controls whether to show turn duration message (e.g., "Cooked for 1m 6s") /** * @deprecated Use settings.env instead. */ env: { [key: string]: string } // Environment variables to set for the CLI hasSeenTasksHint?: boolean // Whether the user has seen the tasks hint hasUsedStash?: boolean // Whether the user has used the stash feature (Ctrl+S) hasUsedBackgroundTask?: boolean // Whether the user has backgrounded a task (Ctrl+B) queuedCommandUpHintCount?: number // Counter for how many times the user has seen the queued command up hint diffTool?: DiffTool // Which tool to use for displaying diffs (terminal or vscode)
// Terminal setup state tracking iterm2SetupInProgress?: boolean iterm2BackupPath?: string // Path to the backup file for iTerm2 preferences appleTerminalBackupPath?: string // Path to the backup file for Terminal.app preferences appleTerminalSetupInProgress?: boolean // Whether Terminal.app setup is currently in progress
// Key binding setup tracking shiftEnterKeyBindingInstalled?: boolean // Whether Shift+Enter key binding is installed (for iTerm2 or VSCode) optionAsMetaKeyInstalled?: boolean // Whether Option as Meta key is installed (for Terminal.app)
// IDE configurations autoConnectIde?: boolean // Whether to automatically connect to IDE on startup if exactly one valid IDE is available autoInstallIdeExtension?: boolean // Whether to automatically install IDE extensions when running from within an IDE
// IDE dialogs hasIdeOnboardingBeenShown?: Record<string, boolean> // Map of terminal name to whether IDE onboarding has been shown ideHintShownCount?: number // Number of times the /ide command hint has been shown hasIdeAutoConnectDialogBeenShown?: boolean // Whether the auto-connect IDE dialog has been shown
tipsHistory: { [tipId: string]: number // Key is tipId, value is the numStartups when tip was last shown }
// /buddy companion soul — bones regenerated from userId on read. See src/buddy/. companion?: import('../buddy/types.js').StoredCompanion companionMuted?: boolean
// Feedback survey tracking feedbackSurveyState?: { lastShownTime?: number }
// Transcript share prompt tracking ("Don't ask again") transcriptShareDismissed?: boolean
// Memory usage tracking memoryUsageCount: number // Number of times user has added to memory
// Sonnet-1M configs hasShownS1MWelcomeV2?: Record<string, boolean> // Whether the Sonnet-1M v2 welcome message has been shown per org // Cache of Sonnet-1M subscriber access per org - key is org ID // hasAccess means "hasAccessAsDefault" but the old name is kept for backward // compatibility. s1mAccessCache?: Record< string, { hasAccess: boolean; hasAccessNotAsDefault?: boolean; timestamp: number } > // Cache of Sonnet-1M PayG access per org - key is org ID // hasAccess means "hasAccessAsDefault" but the old name is kept for backward // compatibility. s1mNonSubscriberAccessCache?: Record< string, { hasAccess: boolean; hasAccessNotAsDefault?: boolean; timestamp: number } >
// Guest passes eligibility cache per org - key is org ID passesEligibilityCache?: Record< string, ReferralEligibilityResponse & { timestamp: number } >
// Grove config cache per account - key is account UUID groveConfigCache?: Record< string, { grove_enabled: boolean; timestamp: number } >
// Guest passes upsell tracking passesUpsellSeenCount?: number // Number of times the guest passes upsell has been shown hasVisitedPasses?: boolean // Whether the user has visited /passes command passesLastSeenRemaining?: number // Last seen remaining_passes count — reset upsell when it increases
// Overage credit grant upsell tracking (keyed by org UUID — multi-org users). // Inlined shape (not import()) because config.ts is in the SDK build surface // and the SDK bundler can't resolve CLI service modules. overageCreditGrantCache?: Record< string, { info: { available: boolean eligible: boolean granted: boolean amount_minor_units: number | null currency: string | null } timestamp: number } > overageCreditUpsellSeenCount?: number // Number of times the overage credit upsell has been shown hasVisitedExtraUsage?: boolean // Whether the user has visited /extra-usage — hides credit upsells
// Voice mode notice tracking voiceNoticeSeenCount?: number // Number of times the voice-mode-available notice has been shown voiceLangHintShownCount?: number // Number of times the /voice dictation-language hint has been shown voiceLangHintLastLanguage?: string // Resolved STT language code when the hint was last shown — reset count when it changes voiceFooterHintSeenCount?: number // Number of sessions the "hold X to speak" footer hint has been shown
// Opus 1M merge notice tracking opus1mMergeNoticeSeenCount?: number // Number of times the opus-1m-merge notice has been shown
// Experiment enrollment notice tracking (keyed by experiment id) experimentNoticesSeenCount?: Record<string, number>
// OpusPlan experiment config hasShownOpusPlanWelcome?: Record<string, boolean> // Whether the OpusPlan welcome message has been shown per org
// Queue usage tracking promptQueueUseCount: number // Number of times use has used the prompt queue
// Btw usage tracking btwUseCount: number // Number of times user has used /btw
// Plan mode usage tracking lastPlanModeUse?: number // Timestamp of last plan mode usage
// Subscription notice tracking subscriptionNoticeCount?: number // Number of times the subscription notice has been shown hasAvailableSubscription?: boolean // Cached result of whether user has a subscription available subscriptionUpsellShownCount?: number // Number of times the subscription upsell has been shown (deprecated) recommendedSubscription?: string // Cached config value from Statsig (deprecated)
// Todo feature configuration todoFeatureEnabled: boolean // Whether the todo feature is enabled showExpandedTodos?: boolean // Whether to show todos expanded, even when empty showSpinnerTree?: boolean // Whether to show the teammate spinner tree instead of pills
// First start time tracking firstStartTime?: string // ISO timestamp when Claude Code was first started on this machine
messageIdleNotifThresholdMs: number // How long the user has to have been idle to get a notification that Claude is done generating
githubActionSetupCount?: number // Number of times the user has set up the GitHub Action slackAppInstallCount?: number // Number of times the user has clicked to install the Slack app
// File checkpointing configuration fileCheckpointingEnabled: boolean
// Terminal progress bar configuration (OSC 9;4) terminalProgressBarEnabled: boolean
// Terminal tab status indicator (OSC 21337). When on, emits a colored // dot + status text to the tab sidebar and drops the spinner prefix // from the title (the dot makes it redundant). showStatusInTerminalTab?: boolean
// Push-notification toggles (set via /config). Default off — explicit opt-in required. taskCompleteNotifEnabled?: boolean inputNeededNotifEnabled?: boolean agentPushNotifEnabled?: boolean
// Claude Code usage tracking claudeCodeFirstTokenDate?: string // ISO timestamp of the user's first Claude Code OAuth token
// Model switch callout tracking (ant-only) modelSwitchCalloutDismissed?: boolean // Whether user chose "Don't show again" modelSwitchCalloutLastShown?: number // Timestamp of last shown (don't show for 24h) modelSwitchCalloutVersion?: string
// Effort callout tracking - shown once for Opus 4.6 users effortCalloutDismissed?: boolean // v1 - legacy, read to suppress v2 for Pro users who already saw it effortCalloutV2Dismissed?: boolean
// Remote callout tracking - shown once before first bridge enable remoteDialogSeen?: boolean
// Cross-process backoff for initReplBridge's oauth_expired_unrefreshable skip. // `expiresAt` is the dedup key — content-addressed, self-clears when /login // replaces the token. `failCount` caps false positives: transient refresh // failures (auth server 5xx, lock errors) get 3 retries before backoff kicks // in, mirroring useReplBridge's MAX_CONSECUTIVE_INIT_FAILURES. Dead-token // accounts cap at 3 config writes; healthy+transient-blip self-heals in ~210s. bridgeOauthDeadExpiresAt?: number bridgeOauthDeadFailCount?: number
// Desktop upsell startup dialog tracking desktopUpsellSeenCount?: number // Total showings (max 3) desktopUpsellDismissed?: boolean // "Don't ask again" picked
// Idle-return dialog tracking idleReturnDismissed?: boolean // "Don't ask again" picked
// Opus 4.5 Pro migration tracking opusProMigrationComplete?: boolean opusProMigrationTimestamp?: number
// Sonnet 4.5 1m migration tracking sonnet1m45MigrationComplete?: boolean
// Opus 4.0/4.1 → current Opus migration (shows one-time notif) legacyOpusMigrationTimestamp?: number
// Sonnet 4.5 → 4.6 migration (pro/max/team premium) sonnet45To46MigrationTimestamp?: number
// Cached statsig gate values cachedStatsigGates: { [gateName: string]: boolean }
// Cached statsig dynamic configs cachedDynamicConfigs?: { [configName: string]: unknown }
// Cached GrowthBook feature values cachedGrowthBookFeatures?: { [featureName: string]: unknown }
// Local GrowthBook overrides (ant-only, set via /config Gates tab). // Checked after env-var overrides but before the real resolved value. growthBookOverrides?: { [featureName: string]: unknown }
// Emergency tip tracking - stores the last shown tip to prevent re-showing lastShownEmergencyTip?: string
// File picker gitignore behavior respectGitignore: boolean // Whether file picker should respect .gitignore files (default: true). Note: .ignore files are always respected
// Copy command behavior copyFullResponse: boolean // Whether /copy always copies the full response instead of showing the picker
// Fullscreen in-app text selection behavior copyOnSelect?: boolean // Auto-copy to clipboard on mouse-up (undefined → true; lets cmd+c "work" via no-op)
// GitHub repo path mapping for teleport directory switching // Key: "owner/repo" (lowercase), Value: array of absolute paths where repo is cloned githubRepoPaths?: Record<string, string[]>
// Terminal emulator to launch for claude-cli:// deep links. Captured from // TERM_PROGRAM during interactive sessions since the deep link handler runs // headless (LaunchServices/xdg) with no TERM_PROGRAM set. deepLinkTerminal?: string
// iTerm2 it2 CLI setup iterm2It2SetupComplete?: boolean // Whether it2 setup has been verified preferTmuxOverIterm2?: boolean // User preference to always use tmux over iTerm2 split panes
// Skill usage tracking for autocomplete ranking skillUsage?: Record<string, { usageCount: number; lastUsedAt: number }> // Official marketplace auto-install tracking officialMarketplaceAutoInstallAttempted?: boolean // Whether auto-install was attempted officialMarketplaceAutoInstalled?: boolean // Whether auto-install succeeded officialMarketplaceAutoInstallFailReason?: | 'policy_blocked' | 'git_unavailable' | 'gcs_unavailable' | 'unknown' // Reason for failure if applicable officialMarketplaceAutoInstallRetryCount?: number // Number of retry attempts officialMarketplaceAutoInstallLastAttemptTime?: number // Timestamp of last attempt officialMarketplaceAutoInstallNextRetryTime?: number // Earliest time to retry again
// Claude in Chrome settings hasCompletedClaudeInChromeOnboarding?: boolean // Whether Claude in Chrome onboarding has been shown claudeInChromeDefaultEnabled?: boolean // Whether Claude in Chrome is enabled by default (undefined means platform default) cachedChromeExtensionInstalled?: boolean // Cached result of whether Chrome extension is installed
// Chrome extension pairing state (persisted across sessions) chromeExtension?: { pairedDeviceId?: string pairedDeviceName?: string }
// LSP plugin recommendation preferences lspRecommendationDisabled?: boolean // Disable all LSP plugin recommendations lspRecommendationNeverPlugins?: string[] // Plugin IDs to never suggest lspRecommendationIgnoredCount?: number // Track ignored recommendations (stops after 5)
// Claude Code hint protocol state (<claude-code-hint /> tags from CLIs/SDKs). // Nested by hint type so future types (docs, mcp, ...) slot in without new // top-level keys. claudeCodeHints?: { // Plugin IDs the user has already been prompted for. Show-once semantics: // recorded regardless of yes/no response, never re-prompted. Capped at // 100 entries to bound config growth — past that, hints stop entirely. plugin?: string[] // User chose "don't show plugin installation hints again" from the dialog. disabled?: boolean }
// Permission explainer configuration permissionExplainerEnabled?: boolean // Enable Haiku-generated explanations for permission requests (default: true)
// Teammate spawn mode: 'auto' | 'tmux' | 'in-process' teammateMode?: 'auto' | 'tmux' | 'in-process' // How to spawn teammates (default: 'auto') // Model for new teammates when the tool call doesn't pass one. // undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID. teammateDefaultModel?: string | null
// PR status footer configuration (feature-flagged via GrowthBook) prStatusFooterEnabled?: boolean // Show PR review status in footer (default: true)
// Tmux live panel visibility (ant-only, toggled via Enter on tmux pill) tungstenPanelVisible?: boolean
// Cached org-level fast mode status from the API. // Used to detect cross-session changes and notify users. penguinModeOrgEnabled?: boolean
// Epoch ms when background refreshes last ran (fast mode, quota, passes, client data). // Used with tengu_cicada_nap_ms to throttle API calls startupPrefetchedAt?: number
// Run Remote Control at startup (requires BRIDGE_MODE) // undefined = use default (see getRemoteControlAtStartup() for precedence) remoteControlAtStartup?: boolean
// Cached extra usage disabled reason from the last API response // undefined = no cache, null = extra usage enabled, string = disabled reason. cachedExtraUsageDisabledReason?: string | null
// Auto permissions notification tracking (ant-only) autoPermissionsNotificationCount?: number // Number of times the auto permissions notification has been shown
// Speculation configuration (ant-only) speculationEnabled?: boolean // Whether speculation is enabled (default: true)
// Client data for server-side experiments (fetched during bootstrap). clientDataCache?: Record<string, unknown> | null
// Additional model options for the model picker (fetched during bootstrap). additionalModelOptionsCache?: ModelOption[]
// Disk cache for /api/claude_code/organizations/metrics_enabled. // Org-level settings change rarely; persisting across processes avoids a // cold API call on every `claude -p` invocation. metricsStatusCache?: { enabled: boolean timestamp: number }
// Version of the last-applied migration set. When equal to // CURRENT_MIGRATION_VERSION, runMigrations() skips all sync migrations // (avoiding 11× saveGlobalConfig lock+re-read on every startup). migrationVersion?: number}
/** * Factory for a fresh default GlobalConfig. Used instead of deep-cloning a * shared constant — the nested containers (arrays, records) are all empty, so * a factory gives fresh refs at zero clone cost. */function createDefaultGlobalConfig(): GlobalConfig { return { numStartups: 0, installMethod: undefined, autoUpdates: undefined, theme: 'dark', preferredNotifChannel: 'auto', verbose: false, editorMode: 'normal', autoCompactEnabled: true, showTurnDuration: true, hasSeenTasksHint: false, hasUsedStash: false, hasUsedBackgroundTask: false, queuedCommandUpHintCount: 0, diffTool: 'auto', customApiKeyResponses: { approved: [], rejected: [], }, env: {}, tipsHistory: {}, memoryUsageCount: 0, promptQueueUseCount: 0, btwUseCount: 0, todoFeatureEnabled: true, showExpandedTodos: false, messageIdleNotifThresholdMs: 60000, autoConnectIde: false, autoInstallIdeExtension: true, fileCheckpointingEnabled: true, terminalProgressBarEnabled: true, cachedStatsigGates: {}, cachedDynamicConfigs: {}, cachedGrowthBookFeatures: {}, respectGitignore: true, copyFullResponse: false, }}
export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = createDefaultGlobalConfig()
export const GLOBAL_CONFIG_KEYS = [ 'apiKeyHelper', 'installMethod', 'autoUpdates', 'autoUpdatesProtectedForNative', 'theme', 'verbose', 'preferredNotifChannel', 'shiftEnterKeyBindingInstalled', 'editorMode', 'hasUsedBackslashReturn', 'autoCompactEnabled', 'showTurnDuration', 'diffTool', 'env', 'tipsHistory', 'todoFeatureEnabled', 'showExpandedTodos', 'messageIdleNotifThresholdMs', 'autoConnectIde', 'autoInstallIdeExtension', 'fileCheckpointingEnabled', 'terminalProgressBarEnabled', 'showStatusInTerminalTab', 'taskCompleteNotifEnabled', 'inputNeededNotifEnabled', 'agentPushNotifEnabled', 'respectGitignore', 'claudeInChromeDefaultEnabled', 'hasCompletedClaudeInChromeOnboarding', 'lspRecommendationDisabled', 'lspRecommendationNeverPlugins', 'lspRecommendationIgnoredCount', 'copyFullResponse', 'copyOnSelect', 'permissionExplainerEnabled', 'prStatusFooterEnabled', 'remoteControlAtStartup', 'remoteDialogSeen',] as const
export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]
export function isGlobalConfigKey(key: string): key is GlobalConfigKey { return GLOBAL_CONFIG_KEYS.includes(key as GlobalConfigKey)}
export const PROJECT_CONFIG_KEYS = [ 'allowedTools', 'hasTrustDialogAccepted', 'hasCompletedProjectOnboarding',] as const
export type ProjectConfigKey = (typeof PROJECT_CONFIG_KEYS)[number]
/** * Check if the user has already accepted the trust dialog for the cwd. * * This function traverses parent directories to check if a parent directory * had approval. Accepting trust for a directory implies trust for child * directories. * * @returns Whether the trust dialog has been accepted (i.e. "should not be shown") */let _trustAccepted = false
export function resetTrustDialogAcceptedCacheForTesting(): void { _trustAccepted = false}
export function checkHasTrustDialogAccepted(): boolean { // Trust only transitions false→true during a session (never the reverse), // so once true we can latch it. false is not cached — it gets re-checked // on every call so that trust dialog acceptance is picked up mid-session. // (lodash memoize doesn't fit here because it would also cache false.) return (_trustAccepted ||= computeTrustDialogAccepted())}
function computeTrustDialogAccepted(): boolean { // Check session-level trust (for home directory case where trust is not persisted) // When running from home dir, trust dialog is shown but acceptance is stored // in memory only. This allows hooks and other features to work during the session. if (getSessionTrustAccepted()) { return true }
const config = getGlobalConfig()
// Always check where trust would be saved (git root or original cwd) // This is the primary location where trust is persisted by saveCurrentProjectConfig const projectPath = getProjectPathForConfig() const projectConfig = config.projects?.[projectPath] if (projectConfig?.hasTrustDialogAccepted) { return true }
// Now check from current working directory and its parents // Normalize paths for consistent JSON key lookup let currentPath = normalizePathForConfigKey(getCwd())
// Traverse all parent directories while (true) { const pathConfig = config.projects?.[currentPath] if (pathConfig?.hasTrustDialogAccepted) { return true }
const parentPath = normalizePathForConfigKey(resolve(currentPath, '..')) // Stop if we've reached the root (when parent is same as current) if (parentPath === currentPath) { break } currentPath = parentPath }
return false}
/** * Check trust for an arbitrary directory (not the session cwd). * Walks up from `dir`, returning true if any ancestor has trust persisted. * Unlike checkHasTrustDialogAccepted, this does NOT consult session trust or * the memoized project path — use when the target dir differs from cwd (e.g. * /assistant installing into a user-typed path). */export function isPathTrusted(dir: string): boolean { const config = getGlobalConfig() let currentPath = normalizePathForConfigKey(resolve(dir)) while (true) { if (config.projects?.[currentPath]?.hasTrustDialogAccepted) return true const parentPath = normalizePathForConfigKey(resolve(currentPath, '..')) if (parentPath === currentPath) return false currentPath = parentPath }}
// We have to put this test code here because Jest doesn't support mocking ES modules :Oconst TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = { ...DEFAULT_GLOBAL_CONFIG, autoUpdates: false,}const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = { ...DEFAULT_PROJECT_CONFIG,}
export function isProjectConfigKey(key: string): key is ProjectConfigKey { return PROJECT_CONFIG_KEYS.includes(key as ProjectConfigKey)}
/** * Detect whether writing `fresh` would lose auth/onboarding state that the * in-memory cache still has. This happens when `getConfig` hits a corrupted * or truncated file mid-write (from another process or a non-atomic fallback) * and returns DEFAULT_GLOBAL_CONFIG. Writing that back would permanently * wipe auth. See GH #3117. */function wouldLoseAuthState(fresh: { oauthAccount?: unknown hasCompletedOnboarding?: boolean}): boolean { const cached = globalConfigCache.config if (!cached) return false const lostOauth = cached.oauthAccount !== undefined && fresh.oauthAccount === undefined const lostOnboarding = cached.hasCompletedOnboarding === true && fresh.hasCompletedOnboarding !== true return lostOauth || lostOnboarding}
export function saveGlobalConfig( updater: (currentConfig: GlobalConfig) => GlobalConfig,): void { if (process.env.NODE_ENV === 'test') { const config = updater(TEST_GLOBAL_CONFIG_FOR_TESTING) // Skip if no changes (same reference returned) if (config === TEST_GLOBAL_CONFIG_FOR_TESTING) { return } Object.assign(TEST_GLOBAL_CONFIG_FOR_TESTING, config) return }
let written: GlobalConfig | null = null try { const didWrite = saveConfigWithLock( getGlobalClaudeFile(), createDefaultGlobalConfig, current => { const config = updater(current) // Skip if no changes (same reference returned) if (config === current) { return current } written = { ...config, projects: removeProjectHistory(current.projects), } return written }, ) // Only write-through if we actually wrote. If the auth-loss guard // tripped (or the updater made no changes), the file is untouched and // the cache is still valid -- touching it would corrupt the guard. if (didWrite && written) { writeThroughGlobalConfigCache(written) } } catch (error) { logForDebugging(`Failed to save config with lock: ${error}`, { level: 'error', }) // Fall back to non-locked version on error. This fallback is a race // window: if another process is mid-write (or the file got truncated), // getConfig returns defaults. Refuse to write those over a good cached // config to avoid wiping auth. See GH #3117. const currentConfig = getConfig( getGlobalClaudeFile(), createDefaultGlobalConfig, ) if (wouldLoseAuthState(currentConfig)) { logForDebugging( 'saveGlobalConfig fallback: re-read config is missing auth that cache has; refusing to write. See GH #3117.', { level: 'error' }, ) logEvent('tengu_config_auth_loss_prevented', {}) return } const config = updater(currentConfig) // Skip if no changes (same reference returned) if (config === currentConfig) { return } written = { ...config, projects: removeProjectHistory(currentConfig.projects), } saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG) writeThroughGlobalConfigCache(written) }}
// Cache for global configlet globalConfigCache: { config: GlobalConfig | null; mtime: number } = { config: null, mtime: 0,}
// Tracking for config file operations (telemetry)let lastReadFileStats: { mtime: number; size: number } | null = nulllet configCacheHits = 0let configCacheMisses = 0// Session-total count of actual disk writes to the global config file.// Exposed for ant-only dev diagnostics (see inc-4552) so anomalous write// rates surface in the UI before they corrupt ~/.claude.json.let globalConfigWriteCount = 0
export function getGlobalConfigWriteCount(): number { return globalConfigWriteCount}
export const CONFIG_WRITE_DISPLAY_THRESHOLD = 20
function reportConfigCacheStats(): void { const total = configCacheHits + configCacheMisses if (total > 0) { logEvent('tengu_config_cache_stats', { cache_hits: configCacheHits, cache_misses: configCacheMisses, hit_rate: configCacheHits / total, }) } configCacheHits = 0 configCacheMisses = 0}
// Register cleanup to report cache stats at session end// eslint-disable-next-line custom-rules/no-top-level-side-effectsregisterCleanup(async () => { reportConfigCacheStats()})
/** * Migrates old autoUpdaterStatus to new installMethod and autoUpdates fields * @internal */function migrateConfigFields(config: GlobalConfig): GlobalConfig { // Already migrated if (config.installMethod !== undefined) { return config }
// autoUpdaterStatus is removed from the type but may exist in old configs const legacy = config as GlobalConfig & { autoUpdaterStatus?: | 'migrated' | 'installed' | 'disabled' | 'enabled' | 'no_permissions' | 'not_configured' }
// Determine install method and auto-update preference from old field let installMethod: InstallMethod = 'unknown' let autoUpdates = config.autoUpdates ?? true // Default to enabled unless explicitly disabled
switch (legacy.autoUpdaterStatus) { case 'migrated': installMethod = 'local' break case 'installed': installMethod = 'native' break case 'disabled': // When disabled, we don't know the install method autoUpdates = false break case 'enabled': case 'no_permissions': case 'not_configured': // These imply global installation installMethod = 'global' break case undefined: // No old status, keep defaults break }
return { ...config, installMethod, autoUpdates, }}
/** * Removes history field from projects (migrated to history.jsonl) * @internal */function removeProjectHistory( projects: Record<string, ProjectConfig> | undefined,): Record<string, ProjectConfig> | undefined { if (!projects) { return projects }
const cleanedProjects: Record<string, ProjectConfig> = {} let needsCleaning = false
for (const [path, projectConfig] of Object.entries(projects)) { // history is removed from the type but may exist in old configs const legacy = projectConfig as ProjectConfig & { history?: unknown } if (legacy.history !== undefined) { needsCleaning = true const { history, ...cleanedConfig } = legacy cleanedProjects[path] = cleanedConfig } else { cleanedProjects[path] = projectConfig } }
return needsCleaning ? cleanedProjects : projects}
// fs.watchFile poll interval for detecting writes from other instances (ms)const CONFIG_FRESHNESS_POLL_MS = 1000let freshnessWatcherStarted = false
// fs.watchFile polls stat on the libuv threadpool and only calls us when mtime// changed — a stalled stat never blocks the main thread.function startGlobalConfigFreshnessWatcher(): void { if (freshnessWatcherStarted || process.env.NODE_ENV === 'test') return freshnessWatcherStarted = true const file = getGlobalClaudeFile() watchFile( file, { interval: CONFIG_FRESHNESS_POLL_MS, persistent: false }, curr => { // Our own writes fire this too — the write-through's Date.now() // overshoot makes cache.mtime > file mtime, so we skip the re-read. // Bun/Node also fire with curr.mtimeMs=0 when the file doesn't exist // (initial callback or deletion) — the <= handles that too. if (curr.mtimeMs <= globalConfigCache.mtime) return void getFsImplementation() .readFile(file, { encoding: 'utf-8' }) .then(content => { // A write-through may have advanced the cache while we were reading; // don't regress to the stale snapshot watchFile stat'd. if (curr.mtimeMs <= globalConfigCache.mtime) return const parsed = safeParseJSON(stripBOM(content)) if (parsed === null || typeof parsed !== 'object') return globalConfigCache = { config: migrateConfigFields({ ...createDefaultGlobalConfig(), ...(parsed as Partial<GlobalConfig>), }), mtime: curr.mtimeMs, } lastReadFileStats = { mtime: curr.mtimeMs, size: curr.size } }) .catch(() => {}) }, ) registerCleanup(async () => { unwatchFile(file) freshnessWatcherStarted = false })}
// Write-through: what we just wrote IS the new config. cache.mtime overshoots// the file's real mtime (Date.now() is recorded after the write) so the// freshness watcher skips re-reading our own write on its next tick.function writeThroughGlobalConfigCache(config: GlobalConfig): void { globalConfigCache = { config, mtime: Date.now() } lastReadFileStats = null}
export function getGlobalConfig(): GlobalConfig { if (process.env.NODE_ENV === 'test') { return TEST_GLOBAL_CONFIG_FOR_TESTING }
// Fast path: pure memory read. After startup, this always hits — our own // writes go write-through and other instances' writes are picked up by the // background freshness watcher (never blocks this path). if (globalConfigCache.config) { configCacheHits++ return globalConfigCache.config }
// Slow path: startup load. Sync I/O here is acceptable because it runs // exactly once, before any UI is rendered. Stat before read so any race // self-corrects (old mtime + new content → watcher re-reads next tick). configCacheMisses++ try { let stats: { mtimeMs: number; size: number } | null = null try { stats = getFsImplementation().statSync(getGlobalClaudeFile()) } catch { // File doesn't exist } const config = migrateConfigFields( getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig), ) globalConfigCache = { config, mtime: stats?.mtimeMs ?? Date.now(), } lastReadFileStats = stats ? { mtime: stats.mtimeMs, size: stats.size } : null startGlobalConfigFreshnessWatcher() return config } catch { // If anything goes wrong, fall back to uncached behavior return migrateConfigFields( getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig), ) }}
/** * Returns the effective value of remoteControlAtStartup. Precedence: * 1. User's explicit config value (always wins — honors opt-out) * 2. CCR auto-connect default (ant-only build, GrowthBook-gated) * 3. false (Remote Control must be explicitly opted into) */export function getRemoteControlAtStartup(): boolean { const explicit = getGlobalConfig().remoteControlAtStartup if (explicit !== undefined) return explicit if (feature('CCR_AUTO_CONNECT')) { if (ccrAutoConnect?.getCcrAutoConnectDefault()) return true } return false}
export function getCustomApiKeyStatus( truncatedApiKey: string,): 'approved' | 'rejected' | 'new' { const config = getGlobalConfig() if (config.customApiKeyResponses?.approved?.includes(truncatedApiKey)) { return 'approved' } if (config.customApiKeyResponses?.rejected?.includes(truncatedApiKey)) { return 'rejected' } return 'new'}
function saveConfig<A extends object>( file: string, config: A, defaultConfig: A,): void { // Ensure the directory exists before writing the config file const dir = dirname(file) const fs = getFsImplementation() // mkdirSync is already recursive in FsOperations implementation fs.mkdirSync(dir)
// Filter out any values that match the defaults const filteredConfig = pickBy( config, (value, key) => jsonStringify(value) !== jsonStringify(defaultConfig[key as keyof A]), ) // Write config file with secure permissions - mode only applies to new files writeFileSyncAndFlush_DEPRECATED( file, jsonStringify(filteredConfig, null, 2), { encoding: 'utf-8', mode: 0o600, }, ) if (file === getGlobalClaudeFile()) { globalConfigWriteCount++ }}
/** * Returns true if a write was performed; false if the write was skipped * (no changes, or auth-loss guard tripped). Callers use this to decide * whether to invalidate the cache -- invalidating after a skipped write * destroys the good cached state the auth-loss guard depends on. */function saveConfigWithLock<A extends object>( file: string, createDefault: () => A, mergeFn: (current: A) => A,): boolean { const defaultConfig = createDefault() const dir = dirname(file) const fs = getFsImplementation()
// Ensure directory exists (mkdirSync is already recursive in FsOperations) fs.mkdirSync(dir)
let release try { const lockFilePath = `${file}.lock` const startTime = Date.now() release = lockfile.lockSync(file, { lockfilePath: lockFilePath, onCompromised: err => { // Default onCompromised throws from a setTimeout callback, which // becomes an unhandled exception. Log instead -- the lock being // stolen (e.g. after a 10s event-loop stall) is recoverable. logForDebugging(`Config lock compromised: ${err}`, { level: 'error' }) }, }) const lockTime = Date.now() - startTime if (lockTime > 100) { logForDebugging( 'Lock acquisition took longer than expected - another Claude instance may be running', ) logEvent('tengu_config_lock_contention', { lock_time_ms: lockTime, }) }
// Check for stale write - file changed since we last read it // Only check for global config file since lastReadFileStats tracks that specific file if (lastReadFileStats && file === getGlobalClaudeFile()) { try { const currentStats = fs.statSync(file) if ( currentStats.mtimeMs !== lastReadFileStats.mtime || currentStats.size !== lastReadFileStats.size ) { logEvent('tengu_config_stale_write', { read_mtime: lastReadFileStats.mtime, write_mtime: currentStats.mtimeMs, read_size: lastReadFileStats.size, write_size: currentStats.size, }) } } catch (e) { const code = getErrnoCode(e) if (code !== 'ENOENT') { throw e } // File doesn't exist yet, no stale check needed } }
// Re-read the current config to get latest state. If the file is // momentarily corrupted (concurrent writes, kill-during-write), this // returns defaults -- we must not write those back over good config. const currentConfig = getConfig(file, createDefault) if (file === getGlobalClaudeFile() && wouldLoseAuthState(currentConfig)) { logForDebugging( 'saveConfigWithLock: re-read config is missing auth that cache has; refusing to write to avoid wiping ~/.claude.json. See GH #3117.', { level: 'error' }, ) logEvent('tengu_config_auth_loss_prevented', {}) return false }
// Apply the merge function to get the updated config const mergedConfig = mergeFn(currentConfig)
// Skip write if no changes (same reference returned) if (mergedConfig === currentConfig) { return false }
// Filter out any values that match the defaults const filteredConfig = pickBy( mergedConfig, (value, key) => jsonStringify(value) !== jsonStringify(defaultConfig[key as keyof A]), )
// Create timestamped backup of existing config before writing // We keep multiple backups to prevent data loss if a reset/corrupted config // overwrites a good backup. Backups are stored in ~/.claude/backups/ to // keep the home directory clean. try { const fileBase = basename(file) const backupDir = getConfigBackupDir()
// Ensure backup directory exists try { fs.mkdirSync(backupDir) } catch (mkdirErr) { const mkdirCode = getErrnoCode(mkdirErr) if (mkdirCode !== 'EEXIST') { throw mkdirErr } }
// Check existing backups first -- skip creating a new one if a recent // backup already exists. During startup, many saveGlobalConfig calls fire // within milliseconds of each other; without this check, each call // creates a new backup file that accumulates on disk. const MIN_BACKUP_INTERVAL_MS = 60_000 const existingBackups = fs .readdirStringSync(backupDir) .filter(f => f.startsWith(`${fileBase}.backup.`)) .sort() .reverse() // Most recent first (timestamps sort lexicographically)
const mostRecentBackup = existingBackups[0] const mostRecentTimestamp = mostRecentBackup ? Number(mostRecentBackup.split('.backup.').pop()) : 0 const shouldCreateBackup = Number.isNaN(mostRecentTimestamp) || Date.now() - mostRecentTimestamp >= MIN_BACKUP_INTERVAL_MS
if (shouldCreateBackup) { const backupPath = join(backupDir, `${fileBase}.backup.${Date.now()}`) fs.copyFileSync(file, backupPath) }
// Clean up old backups, keeping only the 5 most recent const MAX_BACKUPS = 5 // Re-read if we just created one; otherwise reuse the list const backupsForCleanup = shouldCreateBackup ? fs .readdirStringSync(backupDir) .filter(f => f.startsWith(`${fileBase}.backup.`)) .sort() .reverse() : existingBackups
for (const oldBackup of backupsForCleanup.slice(MAX_BACKUPS)) { try { fs.unlinkSync(join(backupDir, oldBackup)) } catch { // Ignore cleanup errors } } } catch (e) { const code = getErrnoCode(e) if (code !== 'ENOENT') { logForDebugging(`Failed to backup config: ${e}`, { level: 'error', }) } // No file to backup or backup failed, continue with write }
// Write config file with secure permissions - mode only applies to new files writeFileSyncAndFlush_DEPRECATED( file, jsonStringify(filteredConfig, null, 2), { encoding: 'utf-8', mode: 0o600, }, ) if (file === getGlobalClaudeFile()) { globalConfigWriteCount++ } return true } finally { if (release) { release() } }}
// Flag to track if config reading is allowedlet configReadingAllowed = false
export function enableConfigs(): void { if (configReadingAllowed) { // Ensure this is idempotent return }
const startTime = Date.now() logForDiagnosticsNoPII('info', 'enable_configs_started')
// Any reads to configuration before this flag is set show an console warning // to prevent us from adding config reading during module initialization configReadingAllowed = true // We only check the global config because currently all the configs share a file getConfig( getGlobalClaudeFile(), createDefaultGlobalConfig, true /* throw on invalid */, )
logForDiagnosticsNoPII('info', 'enable_configs_completed', { duration_ms: Date.now() - startTime, })}
/** * Returns the directory where config backup files are stored. * Uses ~/.claude/backups/ to keep the home directory clean. */function getConfigBackupDir(): string { return join(getClaudeConfigHomeDir(), 'backups')}
/** * Find the most recent backup file for a given config file. * Checks ~/.claude/backups/ first, then falls back to the legacy location * (next to the config file) for backwards compatibility. * Returns the full path to the most recent backup, or null if none exist. */function findMostRecentBackup(file: string): string | null { const fs = getFsImplementation() const fileBase = basename(file) const backupDir = getConfigBackupDir()
// Check the new backup directory first try { const backups = fs .readdirStringSync(backupDir) .filter(f => f.startsWith(`${fileBase}.backup.`)) .sort()
const mostRecent = backups.at(-1) // Timestamps sort lexicographically if (mostRecent) { return join(backupDir, mostRecent) } } catch { // Backup dir doesn't exist yet }
// Fall back to legacy location (next to the config file) const fileDir = dirname(file)
try { const backups = fs .readdirStringSync(fileDir) .filter(f => f.startsWith(`${fileBase}.backup.`)) .sort()
const mostRecent = backups.at(-1) // Timestamps sort lexicographically if (mostRecent) { return join(fileDir, mostRecent) }
// Check for legacy backup file (no timestamp) const legacyBackup = `${file}.backup` try { fs.statSync(legacyBackup) return legacyBackup } catch { // Legacy backup doesn't exist } } catch { // Ignore errors reading directory }
return null}
function getConfig<A>( file: string, createDefault: () => A, throwOnInvalid?: boolean,): A { // Log a warning if config is accessed before it's allowed if (!configReadingAllowed && process.env.NODE_ENV !== 'test') { throw new Error('Config accessed before allowed.') }
const fs = getFsImplementation()
try { const fileContent = fs.readFileSync(file, { encoding: 'utf-8', }) try { // Strip BOM before parsing - PowerShell 5.x adds BOM to UTF-8 files const parsedConfig = jsonParse(stripBOM(fileContent)) return { ...createDefault(), ...parsedConfig, } } catch (error) { // Throw a ConfigParseError with the file path and default config const errorMessage = error instanceof Error ? error.message : String(error) throw new ConfigParseError(errorMessage, file, createDefault()) } } catch (error) { // Handle file not found - check for backup and return default const errCode = getErrnoCode(error) if (errCode === 'ENOENT') { const backupPath = findMostRecentBackup(file) if (backupPath) { process.stderr.write( `\nClaude configuration file not found at: ${file}\n` + `A backup file exists at: ${backupPath}\n` + `You can manually restore it by running: cp "${backupPath}" "${file}"\n\n`, ) } return createDefault() }
// Re-throw ConfigParseError if throwOnInvalid is true if (error instanceof ConfigParseError && throwOnInvalid) { throw error }
// Log config parse errors so users know what happened if (error instanceof ConfigParseError) { logForDebugging( `Config file corrupted, resetting to defaults: ${error.message}`, { level: 'error' }, )
// Guard: logEvent → shouldSampleEvent → getGlobalConfig → getConfig // causes infinite recursion when the config file is corrupted, because // the sampling check reads a GrowthBook feature from global config. // Only log analytics on the outermost call. if (!insideGetConfig) { insideGetConfig = true try { // Log the error for monitoring logError(error)
// Log analytics event for config corruption let hasBackup = false try { fs.statSync(`${file}.backup`) hasBackup = true } catch { // No backup } logEvent('tengu_config_parse_error', { has_backup: hasBackup, }) } finally { insideGetConfig = false } }
process.stderr.write( `\nClaude configuration file at ${file} is corrupted: ${error.message}\n`, )
// Try to backup the corrupted config file (only if not already backed up) const fileBase = basename(file) const corruptedBackupDir = getConfigBackupDir()
// Ensure backup directory exists try { fs.mkdirSync(corruptedBackupDir) } catch (mkdirErr) { const mkdirCode = getErrnoCode(mkdirErr) if (mkdirCode !== 'EEXIST') { throw mkdirErr } }
const existingCorruptedBackups = fs .readdirStringSync(corruptedBackupDir) .filter(f => f.startsWith(`${fileBase}.corrupted.`))
let corruptedBackupPath: string | undefined let alreadyBackedUp = false
// Check if current corrupted content matches any existing backup const currentContent = fs.readFileSync(file, { encoding: 'utf-8' }) for (const backup of existingCorruptedBackups) { try { const backupContent = fs.readFileSync( join(corruptedBackupDir, backup), { encoding: 'utf-8' }, ) if (currentContent === backupContent) { alreadyBackedUp = true break } } catch { // Ignore read errors on backups } }
if (!alreadyBackedUp) { corruptedBackupPath = join( corruptedBackupDir, `${fileBase}.corrupted.${Date.now()}`, ) try { fs.copyFileSync(file, corruptedBackupPath) logForDebugging( `Corrupted config backed up to: ${corruptedBackupPath}`, { level: 'error', }, ) } catch { // Ignore backup errors } }
// Notify user about corrupted config and available backup const backupPath = findMostRecentBackup(file) if (corruptedBackupPath) { process.stderr.write( `The corrupted file has been backed up to: ${corruptedBackupPath}\n`, ) } else if (alreadyBackedUp) { process.stderr.write(`The corrupted file has already been backed up.\n`) }
if (backupPath) { process.stderr.write( `A backup file exists at: ${backupPath}\n` + `You can manually restore it by running: cp "${backupPath}" "${file}"\n\n`, ) } else { process.stderr.write(`\n`) } }
return createDefault() }}
// Memoized function to get the project path for config lookupexport const getProjectPathForConfig = memoize((): string => { const originalCwd = getOriginalCwd() const gitRoot = findCanonicalGitRoot(originalCwd)
if (gitRoot) { // Normalize for consistent JSON keys (forward slashes on all platforms) // This ensures paths like C:\Users\... and C:/Users/... map to the same key return normalizePathForConfigKey(gitRoot) }
// Not in a git repo return normalizePathForConfigKey(resolve(originalCwd))})
export function getCurrentProjectConfig(): ProjectConfig { if (process.env.NODE_ENV === 'test') { return TEST_PROJECT_CONFIG_FOR_TESTING }
const absolutePath = getProjectPathForConfig() const config = getGlobalConfig()
if (!config.projects) { return DEFAULT_PROJECT_CONFIG }
const projectConfig = config.projects[absolutePath] ?? DEFAULT_PROJECT_CONFIG // Not sure how this became a string // TODO: Fix upstream if (typeof projectConfig.allowedTools === 'string') { projectConfig.allowedTools = (safeParseJSON(projectConfig.allowedTools) as string[]) ?? [] }
return projectConfig}
export function saveCurrentProjectConfig( updater: (currentConfig: ProjectConfig) => ProjectConfig,): void { if (process.env.NODE_ENV === 'test') { const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING) // Skip if no changes (same reference returned) if (config === TEST_PROJECT_CONFIG_FOR_TESTING) { return } Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config) return } const absolutePath = getProjectPathForConfig()
let written: GlobalConfig | null = null try { const didWrite = saveConfigWithLock( getGlobalClaudeFile(), createDefaultGlobalConfig, current => { const currentProjectConfig = current.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG const newProjectConfig = updater(currentProjectConfig) // Skip if no changes (same reference returned) if (newProjectConfig === currentProjectConfig) { return current } written = { ...current, projects: { ...current.projects, [absolutePath]: newProjectConfig, }, } return written }, ) if (didWrite && written) { writeThroughGlobalConfigCache(written) } } catch (error) { logForDebugging(`Failed to save config with lock: ${error}`, { level: 'error', })
// Same race window as saveGlobalConfig's fallback -- refuse to write // defaults over good cached config. See GH #3117. const config = getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig) if (wouldLoseAuthState(config)) { logForDebugging( 'saveCurrentProjectConfig fallback: re-read config is missing auth that cache has; refusing to write. See GH #3117.', { level: 'error' }, ) logEvent('tengu_config_auth_loss_prevented', {}) return } const currentProjectConfig = config.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG const newProjectConfig = updater(currentProjectConfig) // Skip if no changes (same reference returned) if (newProjectConfig === currentProjectConfig) { return } written = { ...config, projects: { ...config.projects, [absolutePath]: newProjectConfig, }, } saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG) writeThroughGlobalConfigCache(written) }}
export function isAutoUpdaterDisabled(): boolean { return getAutoUpdaterDisabledReason() !== null}
/** * Returns true if plugin autoupdate should be skipped. * This checks if the auto-updater is disabled AND the FORCE_AUTOUPDATE_PLUGINS * env var is not set to 'true'. The env var allows forcing plugin autoupdate * even when the auto-updater is otherwise disabled. */export function shouldSkipPluginAutoupdate(): boolean { return ( isAutoUpdaterDisabled() && !isEnvTruthy(process.env.FORCE_AUTOUPDATE_PLUGINS) )}
export type AutoUpdaterDisabledReason = | { type: 'development' } | { type: 'env'; envVar: string } | { type: 'config' }
export function formatAutoUpdaterDisabledReason( reason: AutoUpdaterDisabledReason,): string { switch (reason.type) { case 'development': return 'development build' case 'env': return `${reason.envVar} set` case 'config': return 'config' }}
export function getAutoUpdaterDisabledReason(): AutoUpdaterDisabledReason | null { if (process.env.NODE_ENV === 'development') { return { type: 'development' } } if (isEnvTruthy(process.env.DISABLE_AUTOUPDATER)) { return { type: 'env', envVar: 'DISABLE_AUTOUPDATER' } } const essentialTrafficEnvVar = getEssentialTrafficOnlyReason() if (essentialTrafficEnvVar) { return { type: 'env', envVar: essentialTrafficEnvVar } } const config = getGlobalConfig() if ( config.autoUpdates === false && (config.installMethod !== 'native' || config.autoUpdatesProtectedForNative !== true) ) { return { type: 'config' } } return null}
export function getOrCreateUserID(): string { const config = getGlobalConfig() if (config.userID) { return config.userID }
const userID = randomBytes(32).toString('hex') saveGlobalConfig(current => ({ ...current, userID })) return userID}
export function recordFirstStartTime(): void { const config = getGlobalConfig() if (!config.firstStartTime) { const firstStartTime = new Date().toISOString() saveGlobalConfig(current => ({ ...current, firstStartTime: current.firstStartTime ?? firstStartTime, })) }}
export function getMemoryPath(memoryType: MemoryType): string { const cwd = getOriginalCwd()
switch (memoryType) { case 'User': return join(getClaudeConfigHomeDir(), 'CLAUDE.md') case 'Local': return join(cwd, 'CLAUDE.local.md') case 'Project': return join(cwd, 'CLAUDE.md') case 'Managed': return join(getManagedFilePath(), 'CLAUDE.md') case 'AutoMem': return getAutoMemEntrypoint() } // TeamMem is only a valid MemoryType when feature('TEAMMEM') is true if (feature('TEAMMEM')) { return teamMemPaths!.getTeamMemEntrypoint() } return '' // unreachable in external builds where TeamMem is not in MemoryType}
export function getManagedClaudeRulesDir(): string { return join(getManagedFilePath(), '.claude', 'rules')}
export function getUserClaudeRulesDir(): string { return join(getClaudeConfigHomeDir(), 'rules')}
// Exported for testing onlyexport const _getConfigForTesting = getConfigexport const _wouldLoseAuthStateForTesting = wouldLoseAuthStateexport function _setGlobalConfigCacheForTesting( config: GlobalConfig | null,): void { globalConfigCache.config = config globalConfigCache.mtime = config ? Date.now() : 0}