π File detail
services/api/grove.ts
π― Use case
This file lives under βservices/β, which covers long-lived services (LSP, MCP, OAuth, tool execution, memory, compaction, voice, settings sync, β¦). On the API surface it exposes AccountSettings, GroveConfig, ApiResult, getGroveSettings, and markGroveNoticeViewed (and more) β mainly functions, hooks, or classes. Dependencies touch HTTP client, lodash-es, and src. It composes internal code from constants and utils (relative imports).
Generated from folder role, exports, dependency roots, and inline comments β not hand-reviewed for every path.
π§ Inline summary
import axios from 'axios' import memoize from 'lodash-es/memoize.js' import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent,
π€ Exports (heuristic)
AccountSettingsGroveConfigApiResultgetGroveSettingsmarkGroveNoticeViewedupdateGroveSettingsisQualifiedForGrovegetGroveNoticeConfigcalculateShouldShowGrovecheckGroveForNonInteractive
π External import roots
Package roots from from "β¦" (relative paths omitted).
axioslodash-essrc
π₯οΈ Source preview
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<T> = { 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<ApiResult<AccountSettings>> => {
// 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<AccountSettings>(
`${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<void> {
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<void> {
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<boolean> {
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<void> {
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<ApiResult<GroveConfig>> => {
// 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<GroveConfig>(
`${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<AccountSettings>,
configResult: ApiResult<GroveConfig>,
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<void> {
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)
}
}
}