import { 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')) : null const 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 pastedText?: string } export interface HistoryEntry { display: string pastedContents: Record } export type ReleaseChannel = 'stable' | 'latest' export type ProjectConfig = { allowedTools: string[] mcpContextUris: string[] mcpServers?: Record 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 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 releases export 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 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 // 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 // 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 // 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 // OpusPlan experiment config hasShownOpusPlanWelcome?: Record // 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 // 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 // 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 ( 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 | 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 :O const 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 config let globalConfigCache: { config: GlobalConfig | null; mtime: number } = { config: null, mtime: 0, } // Tracking for config file operations (telemetry) let lastReadFileStats: { mtime: number; size: number } | null = null let configCacheHits = 0 let 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-effects registerCleanup(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 | undefined, ): Record | undefined { if (!projects) { return projects } const cleanedProjects: Record = {} 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 = 1000 let 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), }), 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( 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( 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 allowed let 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( 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 lookup export 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 only export const _getConfigForTesting = getConfig export const _wouldLoseAuthStateForTesting = wouldLoseAuthState export function _setGlobalConfigCacheForTesting( config: GlobalConfig | null, ): void { globalConfigCache.config = config globalConfigCache.mtime = config ? Date.now() : 0 }