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