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 }