/ src / utils / config.ts
config.ts
  1  import { existsSync, readFileSync, writeFileSync } from 'fs'
  2  import { resolve, join } from 'path'
  3  import { cloneDeep, memoize, pick } from 'lodash-es'
  4  import { homedir } from 'os'
  5  import { GLOBAL_CLAUDE_FILE } from './env.js'
  6  import { getCwd } from './state.js'
  7  import { randomBytes } from 'crypto'
  8  import { safeParseJSON } from './json.js'
  9  import { checkGate, logEvent } from '../services/statsig.js'
 10  import { GATE_USE_EXTERNAL_UPDATER } from '../constants/betas.js'
 11  import { ConfigParseError } from './errors.js'
 12  import type { ThemeNames } from './theme.js'
 13  
 14  export type McpStdioServerConfig = {
 15    type?: 'stdio' // Optional for backwards compatibility
 16    command: string
 17    args: string[]
 18    env?: Record<string, string>
 19  }
 20  
 21  export type McpSSEServerConfig = {
 22    type: 'sse'
 23    url: string
 24  }
 25  
 26  export type McpServerConfig = McpStdioServerConfig | McpSSEServerConfig
 27  
 28  export type ProjectConfig = {
 29    allowedTools: string[]
 30    context: Record<string, string>
 31    contextFiles?: string[]
 32    history: string[]
 33    dontCrawlDirectory?: boolean
 34    enableArchitectTool?: boolean
 35    mcpContextUris: string[]
 36    mcpServers?: Record<string, McpServerConfig>
 37    approvedMcprcServers?: string[]
 38    rejectedMcprcServers?: string[]
 39    lastAPIDuration?: number
 40    lastCost?: number
 41    lastDuration?: number
 42    lastSessionId?: string
 43    exampleFiles?: string[]
 44    exampleFilesGeneratedAt?: number
 45    hasTrustDialogAccepted?: boolean
 46    hasCompletedProjectOnboarding?: boolean
 47  }
 48  
 49  const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
 50    allowedTools: [],
 51    context: {},
 52    history: [],
 53    dontCrawlDirectory: false,
 54    enableArchitectTool: false,
 55    mcpContextUris: [],
 56    mcpServers: {},
 57    approvedMcprcServers: [],
 58    rejectedMcprcServers: [],
 59    hasTrustDialogAccepted: false,
 60  }
 61  
 62  function defaultConfigForProject(projectPath: string): ProjectConfig {
 63    const config = { ...DEFAULT_PROJECT_CONFIG }
 64    if (projectPath === homedir()) {
 65      config.dontCrawlDirectory = true
 66    }
 67    return config
 68  }
 69  
 70  export type AutoUpdaterStatus =
 71    | 'disabled'
 72    | 'enabled'
 73    | 'no_permissions'
 74    | 'not_configured'
 75  
 76  export function isAutoUpdaterStatus(value: string): value is AutoUpdaterStatus {
 77    return ['disabled', 'enabled', 'no_permissions', 'not_configured'].includes(
 78      value as AutoUpdaterStatus,
 79    )
 80  }
 81  
 82  export type NotificationChannel =
 83    | 'iterm2'
 84    | 'terminal_bell'
 85    | 'iterm2_with_bell'
 86    | 'notifications_disabled'
 87  
 88  export type AccountInfo = {
 89    accountUuid: string
 90    emailAddress: string
 91    organizationUuid?: string
 92  }
 93  
 94  export type GlobalConfig = {
 95    projects?: Record<string, ProjectConfig>
 96    numStartups: number
 97    autoUpdaterStatus?: AutoUpdaterStatus
 98    userID?: string
 99    theme: ThemeNames
100    hasCompletedOnboarding?: boolean
101    // Tracks the last version that reset onboarding, used with MIN_VERSION_REQUIRING_ONBOARDING_RESET
102    lastOnboardingVersion?: string
103    // Tracks the last version for which release notes were seen, used for managing release notes
104    lastReleaseNotesSeen?: string
105    mcpServers?: Record<string, McpServerConfig>
106    preferredNotifChannel: NotificationChannel
107    verbose: boolean
108    customApiKeyResponses?: {
109      approved?: string[]
110      rejected?: string[]
111    }
112    primaryApiKey?: string // Primary API key for the user when no environment variable is set, set via oauth (TODO: rename)
113    hasAcknowledgedCostThreshold?: boolean
114    oauthAccount?: AccountInfo
115    iterm2KeyBindingInstalled?: boolean // Legacy - keeping for backward compatibility
116    shiftEnterKeyBindingInstalled?: boolean
117  }
118  
119  export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
120    numStartups: 0,
121    autoUpdaterStatus: 'not_configured',
122    theme: 'dark' as ThemeNames,
123    preferredNotifChannel: 'iterm2',
124    verbose: false,
125    customApiKeyResponses: {
126      approved: [],
127      rejected: [],
128    },
129  }
130  
131  export const GLOBAL_CONFIG_KEYS = [
132    'autoUpdaterStatus',
133    'theme',
134    'hasCompletedOnboarding',
135    'lastOnboardingVersion',
136    'lastReleaseNotesSeen',
137    'verbose',
138    'customApiKeyResponses',
139    'primaryApiKey',
140    'preferredNotifChannel',
141    'shiftEnterKeyBindingInstalled',
142  ] as const
143  
144  export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]
145  
146  export function isGlobalConfigKey(key: string): key is GlobalConfigKey {
147    return GLOBAL_CONFIG_KEYS.includes(key as GlobalConfigKey)
148  }
149  
150  export const PROJECT_CONFIG_KEYS = [
151    'dontCrawlDirectory',
152    'enableArchitectTool',
153    'hasTrustDialogAccepted',
154    'hasCompletedProjectOnboarding',
155  ] as const
156  
157  export type ProjectConfigKey = (typeof PROJECT_CONFIG_KEYS)[number]
158  
159  export function checkHasTrustDialogAccepted(): boolean {
160    let currentPath = getCwd()
161    const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
162  
163    while (true) {
164      const projectConfig = config.projects?.[currentPath]
165      if (projectConfig?.hasTrustDialogAccepted) {
166        return true
167      }
168      const parentPath = resolve(currentPath, '..')
169      // Stop if we've reached the root (when parent is same as current)
170      if (parentPath === currentPath) {
171        break
172      }
173      currentPath = parentPath
174    }
175  
176    return false
177  }
178  
179  // We have to put this test code here because Jest doesn't support mocking ES modules :O
180  const TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = {
181    ...DEFAULT_GLOBAL_CONFIG,
182    autoUpdaterStatus: 'disabled',
183  }
184  const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = {
185    ...DEFAULT_PROJECT_CONFIG,
186  }
187  
188  export function isProjectConfigKey(key: string): key is ProjectConfigKey {
189    return PROJECT_CONFIG_KEYS.includes(key as ProjectConfigKey)
190  }
191  
192  export function saveGlobalConfig(config: GlobalConfig): void {
193    if (process.env.NODE_ENV === 'test') {
194      for (const key in config) {
195        // @ts-expect-error: TODO
196        TEST_GLOBAL_CONFIG_FOR_TESTING[key] = config[key]
197      }
198      return
199    }
200    saveConfig(
201      GLOBAL_CLAUDE_FILE,
202      {
203        ...config,
204        projects: getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG).projects,
205      },
206      DEFAULT_GLOBAL_CONFIG,
207    )
208  }
209  
210  export function getGlobalConfig(): GlobalConfig {
211    if (process.env.NODE_ENV === 'test') {
212      return TEST_GLOBAL_CONFIG_FOR_TESTING
213    }
214    return getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
215  }
216  
217  export function getAnthropicApiKey(): null | string {
218    const config = getGlobalConfig()
219  
220    if (process.env.USER_TYPE === 'SWE_BENCH') {
221      return process.env.ANTHROPIC_API_KEY_OVERRIDE ?? null
222    }
223  
224    if (process.env.USER_TYPE === 'external') {
225      return config.primaryApiKey ?? null
226    }
227  
228    if (process.env.USER_TYPE === 'ant') {
229      if (
230        process.env.ANTHROPIC_API_KEY &&
231        config.customApiKeyResponses?.approved?.includes(
232          normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY),
233        )
234      ) {
235        return process.env.ANTHROPIC_API_KEY
236      }
237      return config.primaryApiKey ?? null
238    }
239  
240    return null
241  }
242  
243  export function normalizeApiKeyForConfig(apiKey: string): string {
244    return apiKey.slice(-20)
245  }
246  
247  export function isDefaultApiKey(): boolean {
248    const config = getGlobalConfig()
249    const apiKey = getAnthropicApiKey()
250    return apiKey === config.primaryApiKey
251  }
252  
253  export function getCustomApiKeyStatus(
254    truncatedApiKey: string,
255  ): 'approved' | 'rejected' | 'new' {
256    const config = getGlobalConfig()
257    if (config.customApiKeyResponses?.approved?.includes(truncatedApiKey)) {
258      return 'approved'
259    }
260    if (config.customApiKeyResponses?.rejected?.includes(truncatedApiKey)) {
261      return 'rejected'
262    }
263    return 'new'
264  }
265  
266  function saveConfig<A extends object>(
267    file: string,
268    config: A,
269    defaultConfig: A,
270  ): void {
271    // Filter out any values that match the defaults
272    const filteredConfig = Object.fromEntries(
273      Object.entries(config).filter(
274        ([key, value]) =>
275          JSON.stringify(value) !== JSON.stringify(defaultConfig[key as keyof A]),
276      ),
277    )
278    writeFileSync(file, JSON.stringify(filteredConfig, null, 2), 'utf-8')
279  }
280  
281  // Flag to track if config reading is allowed
282  let configReadingAllowed = false
283  
284  export function enableConfigs(): void {
285    // Any reads to configuration before this flag is set show an console warning
286    // to prevent us from adding config reading during module initialization
287    configReadingAllowed = true
288    // We only check the global config because currently all the configs share a file
289    getConfig(
290      GLOBAL_CLAUDE_FILE,
291      DEFAULT_GLOBAL_CONFIG,
292      true /* throw on invalid */,
293    )
294  }
295  
296  function getConfig<A>(
297    file: string,
298    defaultConfig: A,
299    throwOnInvalid?: boolean,
300  ): A {
301    // Log a warning if config is accessed before it's allowed
302    if (!configReadingAllowed && process.env.NODE_ENV !== 'test') {
303      throw new Error('Config accessed before allowed.')
304    }
305  
306    if (!existsSync(file)) {
307      return cloneDeep(defaultConfig)
308    }
309    try {
310      const fileContent = readFileSync(file, 'utf-8')
311      try {
312        const parsedConfig = JSON.parse(fileContent)
313        return {
314          ...cloneDeep(defaultConfig),
315          ...parsedConfig,
316        }
317      } catch (error) {
318        // Throw a ConfigParseError with the file path and default config
319        const errorMessage =
320          error instanceof Error ? error.message : String(error)
321        throw new ConfigParseError(errorMessage, file, defaultConfig)
322      }
323    } catch (error: unknown) {
324      // Re-throw ConfigParseError if throwOnInvalid is true
325      if (error instanceof ConfigParseError && throwOnInvalid) {
326        throw error
327      }
328      return cloneDeep(defaultConfig)
329    }
330  }
331  
332  export function getCurrentProjectConfig(): ProjectConfig {
333    if (process.env.NODE_ENV === 'test') {
334      return TEST_PROJECT_CONFIG_FOR_TESTING
335    }
336  
337    const absolutePath = resolve(getCwd())
338    const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
339  
340    if (!config.projects) {
341      return defaultConfigForProject(absolutePath)
342    }
343  
344    const projectConfig =
345      config.projects[absolutePath] ?? defaultConfigForProject(absolutePath)
346    // Not sure how this became a string
347    // TODO: Fix upstream
348    if (typeof projectConfig.allowedTools === 'string') {
349      projectConfig.allowedTools =
350        (safeParseJSON(projectConfig.allowedTools) as string[]) ?? []
351    }
352    return projectConfig
353  }
354  
355  export function saveCurrentProjectConfig(projectConfig: ProjectConfig): void {
356    if (process.env.NODE_ENV === 'test') {
357      for (const key in projectConfig) {
358        // @ts-expect-error: TODO
359        TEST_PROJECT_CONFIG_FOR_TESTING[key] = projectConfig[key]
360      }
361      return
362    }
363    const config = getConfig(GLOBAL_CLAUDE_FILE, DEFAULT_GLOBAL_CONFIG)
364    saveConfig(
365      GLOBAL_CLAUDE_FILE,
366      {
367        ...config,
368        projects: {
369          ...config.projects,
370          [resolve(getCwd())]: projectConfig,
371        },
372      },
373      DEFAULT_GLOBAL_CONFIG,
374    )
375  }
376  
377  export async function isAutoUpdaterDisabled(): Promise<boolean> {
378    const useExternalUpdater = await checkGate(GATE_USE_EXTERNAL_UPDATER)
379    return (
380      useExternalUpdater || getGlobalConfig().autoUpdaterStatus === 'disabled'
381    )
382  }
383  
384  export const TEST_MCPRC_CONFIG_FOR_TESTING: Record<string, McpServerConfig> = {}
385  
386  export function clearMcprcConfigForTesting(): void {
387    if (process.env.NODE_ENV === 'test') {
388      Object.keys(TEST_MCPRC_CONFIG_FOR_TESTING).forEach(key => {
389        delete TEST_MCPRC_CONFIG_FOR_TESTING[key]
390      })
391    }
392  }
393  
394  export function addMcprcServerForTesting(
395    name: string,
396    server: McpServerConfig,
397  ): void {
398    if (process.env.NODE_ENV === 'test') {
399      TEST_MCPRC_CONFIG_FOR_TESTING[name] = server
400    }
401  }
402  
403  export function removeMcprcServerForTesting(name: string): void {
404    if (process.env.NODE_ENV === 'test') {
405      if (!TEST_MCPRC_CONFIG_FOR_TESTING[name]) {
406        throw new Error(`No MCP server found with name: ${name} in .mcprc`)
407      }
408      delete TEST_MCPRC_CONFIG_FOR_TESTING[name]
409    }
410  }
411  
412  export const getMcprcConfig = memoize(
413    (): Record<string, McpServerConfig> => {
414      if (process.env.NODE_ENV === 'test') {
415        return TEST_MCPRC_CONFIG_FOR_TESTING
416      }
417  
418      const mcprcPath = join(getCwd(), '.mcprc')
419      if (!existsSync(mcprcPath)) {
420        return {}
421      }
422  
423      try {
424        const mcprcContent = readFileSync(mcprcPath, 'utf-8')
425        const config = safeParseJSON(mcprcContent)
426        if (config && typeof config === 'object') {
427          logEvent('tengu_mcprc_found', {
428            numServers: Object.keys(config).length.toString(),
429          })
430          return config as Record<string, McpServerConfig>
431        }
432      } catch {
433        // Ignore errors reading/parsing .mcprc (they're logged in safeParseJSON)
434      }
435      return {}
436    },
437    // This function returns the same value as long as the cwd and mcprc file content remain the same
438    () => {
439      const cwd = getCwd()
440      const mcprcPath = join(cwd, '.mcprc')
441      if (existsSync(mcprcPath)) {
442        try {
443          const stat = readFileSync(mcprcPath, 'utf-8')
444          return `${cwd}:${stat}`
445        } catch {
446          return cwd
447        }
448      }
449      return cwd
450    },
451  )
452  
453  export function getOrCreateUserID(): string {
454    const config = getGlobalConfig()
455    if (config.userID) {
456      return config.userID
457    }
458  
459    const userID = randomBytes(32).toString('hex')
460    saveGlobalConfig({ ...config, userID })
461    return userID
462  }
463  
464  export function getConfigForCLI(key: string, global: boolean): unknown {
465    logEvent('tengu_config_get', {
466      key,
467      global: global?.toString() ?? 'false',
468    })
469    if (global) {
470      if (!isGlobalConfigKey(key)) {
471        console.error(
472          `Error: '${key}' is not a valid config key. Valid keys are: ${GLOBAL_CONFIG_KEYS.join(', ')}`,
473        )
474        process.exit(1)
475      }
476      return getGlobalConfig()[key]
477    } else {
478      if (!isProjectConfigKey(key)) {
479        console.error(
480          `Error: '${key}' is not a valid config key. Valid keys are: ${PROJECT_CONFIG_KEYS.join(', ')}`,
481        )
482        process.exit(1)
483      }
484      return getCurrentProjectConfig()[key]
485    }
486  }
487  
488  export function setConfigForCLI(
489    key: string,
490    value: unknown,
491    global: boolean,
492  ): void {
493    logEvent('tengu_config_set', {
494      key,
495      global: global?.toString() ?? 'false',
496    })
497    if (global) {
498      if (!isGlobalConfigKey(key)) {
499        console.error(
500          `Error: Cannot set '${key}'. Only these keys can be modified: ${GLOBAL_CONFIG_KEYS.join(', ')}`,
501        )
502        process.exit(1)
503      }
504  
505      if (key === 'autoUpdaterStatus' && !isAutoUpdaterStatus(value as string)) {
506        console.error(
507          `Error: Invalid value for autoUpdaterStatus. Must be one of: disabled, enabled, no_permissions, not_configured`,
508        )
509        process.exit(1)
510      }
511  
512      const currentConfig = getGlobalConfig()
513      saveGlobalConfig({
514        ...currentConfig,
515        [key]: value,
516      })
517    } else {
518      if (!isProjectConfigKey(key)) {
519        console.error(
520          `Error: Cannot set '${key}'. Only these keys can be modified: ${PROJECT_CONFIG_KEYS.join(', ')}. Did you mean --global?`,
521        )
522        process.exit(1)
523      }
524      const currentConfig = getCurrentProjectConfig()
525      saveCurrentProjectConfig({
526        ...currentConfig,
527        [key]: value,
528      })
529    }
530    // Wait for the output to be flushed, to avoid clearing the screen.
531    setTimeout(() => {
532      // Without this we hang indefinitely.
533      process.exit(0)
534    }, 100)
535  }
536  
537  export function deleteConfigForCLI(key: string, global: boolean): void {
538    logEvent('tengu_config_delete', {
539      key,
540      global: global?.toString() ?? 'false',
541    })
542    if (global) {
543      if (!isGlobalConfigKey(key)) {
544        console.error(
545          `Error: Cannot delete '${key}'. Only these keys can be modified: ${GLOBAL_CONFIG_KEYS.join(', ')}`,
546        )
547        process.exit(1)
548      }
549      const currentConfig = getGlobalConfig()
550      delete currentConfig[key]
551      saveGlobalConfig(currentConfig)
552    } else {
553      if (!isProjectConfigKey(key)) {
554        console.error(
555          `Error: Cannot delete '${key}'. Only these keys can be modified: ${PROJECT_CONFIG_KEYS.join(', ')}. Did you mean --global?`,
556        )
557        process.exit(1)
558      }
559      const currentConfig = getCurrentProjectConfig()
560      delete currentConfig[key]
561      saveCurrentProjectConfig(currentConfig)
562    }
563  }
564  
565  export function listConfigForCLI(global: true): GlobalConfig
566  export function listConfigForCLI(global: false): ProjectConfig
567  export function listConfigForCLI(global: boolean): object {
568    logEvent('tengu_config_list', {
569      global: global?.toString() ?? 'false',
570    })
571    if (global) {
572      const currentConfig = pick(getGlobalConfig(), GLOBAL_CONFIG_KEYS)
573      return currentConfig
574    } else {
575      return pick(getCurrentProjectConfig(), PROJECT_CONFIG_KEYS)
576    }
577  }