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 { PRODUCT_CONFIG_DIRNAME } from "../constants/product.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 ~/.mervelas/cache/changelog.md) changelogLastFetched?: number; // @deprecated - Migrated to ~/.mervelas/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[]; }; authProvider?: "anthropic" | "openrouter" | "openai" | "opencode" | "nvidia"; primaryApiKey?: string; // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename) openAiApiKey?: string; openAiAccessToken?: string; openAiRefreshToken?: string; openAiTokenExpiresAt?: number; openAiWorkspaceId?: string; openRouterApiKey?: string; openCodeApiKey?: string; nvidiaApiKey?: string; 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 Mervelas 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; // Mervelas usage tracking claudeCodeFirstTokenDate?: string; // ISO timestamp of the user's first Mervelas 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) // Mervelas 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 ~/.mervelas/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 ~/.mervelas/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 ~/.mervelas/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(), "MERVELAS.md"); case "Local": return join(cwd, "MERVELAS.local.md"); case "Project": return join(cwd, "MERVELAS.md"); case "Managed": return join(getManagedFilePath(), "MERVELAS.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(), PRODUCT_CONFIG_DIRNAME, "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; }