import axios from 'axios' import memoize from 'lodash-es/memoize.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, } from 'src/services/analytics/index.js' import { getOauthAccountInfo, isConsumerSubscriber } from 'src/utils/auth.js' import { logForDebugging } from 'src/utils/debug.js' import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' import { isEssentialTrafficOnly } from 'src/utils/privacyLevel.js' import { writeToStderr } from 'src/utils/process.js' import { getOauthConfig } from '../../constants/oauth.js' import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' import { getAuthHeaders, getUserAgent, withOAuth401Retry, } from '../../utils/http.js' import { logError } from '../../utils/log.js' import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' // Cache expiration: 24 hours const GROVE_CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000 export type AccountSettings = { grove_enabled: boolean | null grove_notice_viewed_at: string | null } export type GroveConfig = { grove_enabled: boolean domain_excluded: boolean notice_is_grace_period: boolean notice_reminder_frequency: number | null } /** * Result type that distinguishes between API failure and success. * - success: true means API call succeeded (data may still contain null fields) * - success: false means API call failed after retry */ export type ApiResult = { success: true; data: T } | { success: false } /** * Get the current Grove settings for the user account. * Returns ApiResult to distinguish between API failure and success. * Uses existing OAuth 401 retry, then returns failure if that doesn't help. * * Memoized for the session to avoid redundant per-render requests. * Cache is invalidated in updateGroveSettings() so post-toggle reads are fresh. */ export const getGroveSettings = memoize( async (): Promise> => { // Grove is a notification feature; during an outage, skipping it is correct. if (isEssentialTrafficOnly()) { return { success: false } } try { const response = await withOAuth401Retry(() => { const authHeaders = getAuthHeaders() if (authHeaders.error) { throw new Error(`Failed to get auth headers: ${authHeaders.error}`) } return axios.get( `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, { headers: { ...authHeaders.headers, 'User-Agent': getClaudeCodeUserAgent(), }, }, ) }) return { success: true, data: response.data } } catch (err) { logError(err) // Don't cache failures — transient network issues would lock the user // out of privacy settings for the entire session (deadlock: dialog needs // success to render the toggle, toggle calls updateGroveSettings which // is the only other place the cache is cleared). getGroveSettings.cache.clear?.() return { success: false } } }, ) /** * Mark that the Grove notice has been viewed by the user */ export async function markGroveNoticeViewed(): Promise { try { await withOAuth401Retry(() => { const authHeaders = getAuthHeaders() if (authHeaders.error) { throw new Error(`Failed to get auth headers: ${authHeaders.error}`) } return axios.post( `${getOauthConfig().BASE_API_URL}/api/oauth/account/grove_notice_viewed`, {}, { headers: { ...authHeaders.headers, 'User-Agent': getClaudeCodeUserAgent(), }, }, ) }) // This mutates grove_notice_viewed_at server-side — Grove.tsx:87 reads it // to decide whether to show the dialog. Without invalidation a same-session // remount would read stale viewed_at:null and re-show the dialog. getGroveSettings.cache.clear?.() } catch (err) { logError(err) } } /** * Update Grove settings for the user account */ export async function updateGroveSettings( groveEnabled: boolean, ): Promise { try { await withOAuth401Retry(() => { const authHeaders = getAuthHeaders() if (authHeaders.error) { throw new Error(`Failed to get auth headers: ${authHeaders.error}`) } return axios.patch( `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, { grove_enabled: groveEnabled, }, { headers: { ...authHeaders.headers, 'User-Agent': getClaudeCodeUserAgent(), }, }, ) }) // Invalidate memoized settings so the post-toggle confirmation // read in privacy-settings.tsx picks up the new value. getGroveSettings.cache.clear?.() } catch (err) { logError(err) } } /** * Check if user is qualified for Grove (non-blocking, cache-first). * * This function never blocks on network - it returns cached data immediately * and fetches in the background if needed. On cold start (no cache), it returns * false and the Grove dialog won't show until the next session. */ export async function isQualifiedForGrove(): Promise { if (!isConsumerSubscriber()) { return false } const accountId = getOauthAccountInfo()?.accountUuid if (!accountId) { return false } const globalConfig = getGlobalConfig() const cachedEntry = globalConfig.groveConfigCache?.[accountId] const now = Date.now() // No cache - trigger background fetch and return false (non-blocking) // The Grove dialog won't show this session, but will next time if eligible if (!cachedEntry) { logForDebugging( 'Grove: No cache, fetching config in background (dialog skipped this session)', ) void fetchAndStoreGroveConfig(accountId) return false } // Cache exists but is stale - return cached value and refresh in background if (now - cachedEntry.timestamp > GROVE_CACHE_EXPIRATION_MS) { logForDebugging( 'Grove: Cache stale, returning cached data and refreshing in background', ) void fetchAndStoreGroveConfig(accountId) return cachedEntry.grove_enabled } // Cache is fresh - return it immediately logForDebugging('Grove: Using fresh cached config') return cachedEntry.grove_enabled } /** * Fetch Grove config from API and store in cache */ async function fetchAndStoreGroveConfig(accountId: string): Promise { try { const result = await getGroveNoticeConfig() if (!result.success) { return } const groveEnabled = result.data.grove_enabled const cachedEntry = getGlobalConfig().groveConfigCache?.[accountId] if ( cachedEntry?.grove_enabled === groveEnabled && Date.now() - cachedEntry.timestamp <= GROVE_CACHE_EXPIRATION_MS ) { return } saveGlobalConfig(current => ({ ...current, groveConfigCache: { ...current.groveConfigCache, [accountId]: { grove_enabled: groveEnabled, timestamp: Date.now(), }, }, })) } catch (err) { logForDebugging(`Grove: Failed to fetch and store config: ${err}`) } } /** * Get Grove Statsig configuration from the API. * Returns ApiResult to distinguish between API failure and success. * Uses existing OAuth 401 retry, then returns failure if that doesn't help. */ export const getGroveNoticeConfig = memoize( async (): Promise> => { // Grove is a notification feature; during an outage, skipping it is correct. if (isEssentialTrafficOnly()) { return { success: false } } try { const response = await withOAuth401Retry(() => { const authHeaders = getAuthHeaders() if (authHeaders.error) { throw new Error(`Failed to get auth headers: ${authHeaders.error}`) } return axios.get( `${getOauthConfig().BASE_API_URL}/api/claude_code_grove`, { headers: { ...authHeaders.headers, 'User-Agent': getUserAgent(), }, timeout: 3000, // Short timeout - if slow, skip Grove dialog }, ) }) // Map the API response to the GroveConfig type const { grove_enabled, domain_excluded, notice_is_grace_period, notice_reminder_frequency, } = response.data return { success: true, data: { grove_enabled, domain_excluded: domain_excluded ?? false, notice_is_grace_period: notice_is_grace_period ?? true, notice_reminder_frequency, }, } } catch (err) { logForDebugging(`Failed to fetch Grove notice config: ${err}`) return { success: false } } }, ) /** * Determines whether the Grove dialog should be shown. * Returns false if either API call failed (after retry) - we hide the dialog on API failure. */ export function calculateShouldShowGrove( settingsResult: ApiResult, configResult: ApiResult, showIfAlreadyViewed: boolean, ): boolean { // Hide dialog on API failure (after retry) if (!settingsResult.success || !configResult.success) { return false } const settings = settingsResult.data const config = configResult.data const hasChosen = settings.grove_enabled !== null if (hasChosen) { return false } if (showIfAlreadyViewed) { return true } if (!config.notice_is_grace_period) { return true } // Check if we need to remind the user to accept the terms and choose // whether to help improve Claude. const reminderFrequency = config.notice_reminder_frequency if (reminderFrequency !== null && settings.grove_notice_viewed_at) { const daysSinceViewed = Math.floor( (Date.now() - new Date(settings.grove_notice_viewed_at).getTime()) / (1000 * 60 * 60 * 24), ) return daysSinceViewed >= reminderFrequency } else { // Show if never viewed before const viewedAt = settings.grove_notice_viewed_at return viewedAt === null || viewedAt === undefined } } export async function checkGroveForNonInteractive(): Promise { const [settingsResult, configResult] = await Promise.all([ getGroveSettings(), getGroveNoticeConfig(), ]) // Check if user hasn't made a choice yet (returns false on API failure) const shouldShowGrove = calculateShouldShowGrove( settingsResult, configResult, false, ) if (shouldShowGrove) { // shouldShowGrove is only true if both API calls succeeded const config = configResult.success ? configResult.data : null logEvent('tengu_grove_print_viewed', { dismissable: config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }) if (config === null || config.notice_is_grace_period) { // Grace period is still active - show informational message and continue writeToStderr( '\nAn update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. Run `claude` to review the updated terms.\n\n', ) await markGroveNoticeViewed() } else { // Grace period has ended - show error message and exit writeToStderr( '\n[ACTION REQUIRED] An update to our Consumer Terms and Privacy Policy has taken effect on October 8, 2025. You must run `claude` to review the updated terms.\n\n', ) await gracefulShutdown(1) } } }