source dump of claude code
at main 467 lines 14 kB view raw
1import { feature } from 'bun:bundle' 2import { z } from 'zod/v4' 3import { 4 type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5 logEvent, 6} from '../../services/analytics/index.js' 7import { buildTool, type ToolDef } from '../../Tool.js' 8import { 9 type GlobalConfig, 10 getGlobalConfig, 11 getRemoteControlAtStartup, 12 saveGlobalConfig, 13} from '../../utils/config.js' 14import { errorMessage } from '../../utils/errors.js' 15import { lazySchema } from '../../utils/lazySchema.js' 16import { logError } from '../../utils/log.js' 17import { 18 getInitialSettings, 19 updateSettingsForSource, 20} from '../../utils/settings/settings.js' 21import { jsonStringify } from '../../utils/slowOperations.js' 22import { CONFIG_TOOL_NAME } from './constants.js' 23import { DESCRIPTION, generatePrompt } from './prompt.js' 24import { 25 getConfig, 26 getOptionsForSetting, 27 getPath, 28 isSupported, 29} from './supportedSettings.js' 30import { 31 renderToolResultMessage, 32 renderToolUseMessage, 33 renderToolUseRejectedMessage, 34} from './UI.js' 35 36const inputSchema = lazySchema(() => 37 z.strictObject({ 38 setting: z 39 .string() 40 .describe( 41 'The setting key (e.g., "theme", "model", "permissions.defaultMode")', 42 ), 43 value: z 44 .union([z.string(), z.boolean(), z.number()]) 45 .optional() 46 .describe('The new value. Omit to get current value.'), 47 }), 48) 49type InputSchema = ReturnType<typeof inputSchema> 50 51const outputSchema = lazySchema(() => 52 z.object({ 53 success: z.boolean(), 54 operation: z.enum(['get', 'set']).optional(), 55 setting: z.string().optional(), 56 value: z.unknown().optional(), 57 previousValue: z.unknown().optional(), 58 newValue: z.unknown().optional(), 59 error: z.string().optional(), 60 }), 61) 62type OutputSchema = ReturnType<typeof outputSchema> 63 64export type Input = z.infer<InputSchema> 65export type Output = z.infer<OutputSchema> 66 67export const ConfigTool = buildTool({ 68 name: CONFIG_TOOL_NAME, 69 searchHint: 'get or set Claude Code settings (theme, model)', 70 maxResultSizeChars: 100_000, 71 async description() { 72 return DESCRIPTION 73 }, 74 async prompt() { 75 return generatePrompt() 76 }, 77 get inputSchema(): InputSchema { 78 return inputSchema() 79 }, 80 get outputSchema(): OutputSchema { 81 return outputSchema() 82 }, 83 userFacingName() { 84 return 'Config' 85 }, 86 shouldDefer: true, 87 isConcurrencySafe() { 88 return true 89 }, 90 isReadOnly(input: Input) { 91 return input.value === undefined 92 }, 93 toAutoClassifierInput(input) { 94 return input.value === undefined 95 ? input.setting 96 : `${input.setting} = ${input.value}` 97 }, 98 async checkPermissions(input: Input) { 99 // Auto-allow reading configs 100 if (input.value === undefined) { 101 return { behavior: 'allow' as const, updatedInput: input } 102 } 103 return { 104 behavior: 'ask' as const, 105 message: `Set ${input.setting} to ${jsonStringify(input.value)}`, 106 } 107 }, 108 renderToolUseMessage, 109 renderToolResultMessage, 110 renderToolUseRejectedMessage, 111 async call({ setting, value }: Input, context): Promise<{ data: Output }> { 112 // 1. Check if setting is supported 113 // Voice settings are registered at build-time (feature('VOICE_MODE')), but 114 // must also be gated at runtime. When the kill-switch is on, treat 115 // voiceEnabled as an unknown setting so no voice-specific strings leak. 116 if (feature('VOICE_MODE') && setting === 'voiceEnabled') { 117 const { isVoiceGrowthBookEnabled } = await import( 118 '../../voice/voiceModeEnabled.js' 119 ) 120 if (!isVoiceGrowthBookEnabled()) { 121 return { 122 data: { success: false, error: `Unknown setting: "${setting}"` }, 123 } 124 } 125 } 126 if (!isSupported(setting)) { 127 return { 128 data: { success: false, error: `Unknown setting: "${setting}"` }, 129 } 130 } 131 132 const config = getConfig(setting)! 133 const path = getPath(setting) 134 135 // 2. GET operation 136 if (value === undefined) { 137 const currentValue = getValue(config.source, path) 138 const displayValue = config.formatOnRead 139 ? config.formatOnRead(currentValue) 140 : currentValue 141 return { 142 data: { success: true, operation: 'get', setting, value: displayValue }, 143 } 144 } 145 146 // 3. SET operation 147 148 // Handle "default" — unset the config key so it falls back to the 149 // platform-aware default (determined by the bridge feature gate). 150 if ( 151 setting === 'remoteControlAtStartup' && 152 typeof value === 'string' && 153 value.toLowerCase().trim() === 'default' 154 ) { 155 saveGlobalConfig(prev => { 156 if (prev.remoteControlAtStartup === undefined) return prev 157 const next = { ...prev } 158 delete next.remoteControlAtStartup 159 return next 160 }) 161 const resolved = getRemoteControlAtStartup() 162 // Sync to AppState so useReplBridge reacts immediately 163 context.setAppState(prev => { 164 if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly) 165 return prev 166 return { 167 ...prev, 168 replBridgeEnabled: resolved, 169 replBridgeOutboundOnly: false, 170 } 171 }) 172 return { 173 data: { 174 success: true, 175 operation: 'set', 176 setting, 177 value: resolved, 178 }, 179 } 180 } 181 182 let finalValue: unknown = value 183 184 // Coerce and validate boolean values 185 if (config.type === 'boolean') { 186 if (typeof value === 'string') { 187 const lower = value.toLowerCase().trim() 188 if (lower === 'true') finalValue = true 189 else if (lower === 'false') finalValue = false 190 } 191 if (typeof finalValue !== 'boolean') { 192 return { 193 data: { 194 success: false, 195 operation: 'set', 196 setting, 197 error: `${setting} requires true or false.`, 198 }, 199 } 200 } 201 } 202 203 // Check options 204 const options = getOptionsForSetting(setting) 205 if (options && !options.includes(String(finalValue))) { 206 return { 207 data: { 208 success: false, 209 operation: 'set', 210 setting, 211 error: `Invalid value "${value}". Options: ${options.join(', ')}`, 212 }, 213 } 214 } 215 216 // Async validation (e.g., model API check) 217 if (config.validateOnWrite) { 218 const result = await config.validateOnWrite(finalValue) 219 if (!result.valid) { 220 return { 221 data: { 222 success: false, 223 operation: 'set', 224 setting, 225 error: result.error, 226 }, 227 } 228 } 229 } 230 231 // Pre-flight checks for voice mode 232 if ( 233 feature('VOICE_MODE') && 234 setting === 'voiceEnabled' && 235 finalValue === true 236 ) { 237 const { isVoiceModeEnabled } = await import( 238 '../../voice/voiceModeEnabled.js' 239 ) 240 if (!isVoiceModeEnabled()) { 241 const { isAnthropicAuthEnabled } = await import('../../utils/auth.js') 242 return { 243 data: { 244 success: false, 245 error: !isAnthropicAuthEnabled() 246 ? 'Voice mode requires a Claude.ai account. Please run /login to sign in.' 247 : 'Voice mode is not available.', 248 }, 249 } 250 } 251 const { isVoiceStreamAvailable } = await import( 252 '../../services/voiceStreamSTT.js' 253 ) 254 const { 255 checkRecordingAvailability, 256 checkVoiceDependencies, 257 requestMicrophonePermission, 258 } = await import('../../services/voice.js') 259 260 const recording = await checkRecordingAvailability() 261 if (!recording.available) { 262 return { 263 data: { 264 success: false, 265 error: 266 recording.reason ?? 267 'Voice mode is not available in this environment.', 268 }, 269 } 270 } 271 if (!isVoiceStreamAvailable()) { 272 return { 273 data: { 274 success: false, 275 error: 276 'Voice mode requires a Claude.ai account. Please run /login to sign in.', 277 }, 278 } 279 } 280 const deps = await checkVoiceDependencies() 281 if (!deps.available) { 282 return { 283 data: { 284 success: false, 285 error: 286 'No audio recording tool found.' + 287 (deps.installCommand ? ` Run: ${deps.installCommand}` : ''), 288 }, 289 } 290 } 291 if (!(await requestMicrophonePermission())) { 292 let guidance: string 293 if (process.platform === 'win32') { 294 guidance = 'Settings \u2192 Privacy \u2192 Microphone' 295 } else if (process.platform === 'linux') { 296 guidance = "your system's audio settings" 297 } else { 298 guidance = 299 'System Settings \u2192 Privacy & Security \u2192 Microphone' 300 } 301 return { 302 data: { 303 success: false, 304 error: `Microphone access is denied. To enable it, go to ${guidance}, then try again.`, 305 }, 306 } 307 } 308 } 309 310 const previousValue = getValue(config.source, path) 311 312 // 4. Write to storage 313 try { 314 if (config.source === 'global') { 315 const key = path[0] 316 if (!key) { 317 return { 318 data: { 319 success: false, 320 operation: 'set', 321 setting, 322 error: 'Invalid setting path', 323 }, 324 } 325 } 326 saveGlobalConfig(prev => { 327 if (prev[key as keyof GlobalConfig] === finalValue) return prev 328 return { ...prev, [key]: finalValue } 329 }) 330 } else { 331 const update = buildNestedObject(path, finalValue) 332 const result = updateSettingsForSource('userSettings', update) 333 if (result.error) { 334 return { 335 data: { 336 success: false, 337 operation: 'set', 338 setting, 339 error: result.error.message, 340 }, 341 } 342 } 343 } 344 345 // 5a. Voice needs notifyChange so applySettingsChange resyncs 346 // AppState.settings (useVoiceEnabled reads settings.voiceEnabled) 347 // and the settings cache resets for the next /voice read. 348 if (feature('VOICE_MODE') && setting === 'voiceEnabled') { 349 const { settingsChangeDetector } = await import( 350 '../../utils/settings/changeDetector.js' 351 ) 352 settingsChangeDetector.notifyChange('userSettings') 353 } 354 355 // 5b. Sync to AppState if needed for immediate UI effect 356 if (config.appStateKey) { 357 const appKey = config.appStateKey 358 context.setAppState(prev => { 359 if (prev[appKey] === finalValue) return prev 360 return { ...prev, [appKey]: finalValue } 361 }) 362 } 363 364 // Sync remoteControlAtStartup to AppState so the bridge reacts 365 // immediately (the config key differs from the AppState field name, 366 // so the generic appStateKey mechanism can't handle this). 367 if (setting === 'remoteControlAtStartup') { 368 const resolved = getRemoteControlAtStartup() 369 context.setAppState(prev => { 370 if ( 371 prev.replBridgeEnabled === resolved && 372 !prev.replBridgeOutboundOnly 373 ) 374 return prev 375 return { 376 ...prev, 377 replBridgeEnabled: resolved, 378 replBridgeOutboundOnly: false, 379 } 380 }) 381 } 382 383 logEvent('tengu_config_tool_changed', { 384 setting: 385 setting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 386 value: String( 387 finalValue, 388 ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 389 }) 390 391 return { 392 data: { 393 success: true, 394 operation: 'set', 395 setting, 396 previousValue, 397 newValue: finalValue, 398 }, 399 } 400 } catch (error) { 401 logError(error) 402 return { 403 data: { 404 success: false, 405 operation: 'set', 406 setting, 407 error: errorMessage(error), 408 }, 409 } 410 } 411 }, 412 mapToolResultToToolResultBlockParam(content: Output, toolUseID: string) { 413 if (content.success) { 414 if (content.operation === 'get') { 415 return { 416 tool_use_id: toolUseID, 417 type: 'tool_result' as const, 418 content: `${content.setting} = ${jsonStringify(content.value)}`, 419 } 420 } 421 return { 422 tool_use_id: toolUseID, 423 type: 'tool_result' as const, 424 content: `Set ${content.setting} to ${jsonStringify(content.newValue)}`, 425 } 426 } 427 return { 428 tool_use_id: toolUseID, 429 type: 'tool_result' as const, 430 content: `Error: ${content.error}`, 431 is_error: true, 432 } 433 }, 434} satisfies ToolDef<InputSchema, Output>) 435 436function getValue(source: 'global' | 'settings', path: string[]): unknown { 437 if (source === 'global') { 438 const config = getGlobalConfig() 439 const key = path[0] 440 if (!key) return undefined 441 return config[key as keyof GlobalConfig] 442 } 443 const settings = getInitialSettings() 444 let current: unknown = settings 445 for (const key of path) { 446 if (current && typeof current === 'object' && key in current) { 447 current = (current as Record<string, unknown>)[key] 448 } else { 449 return undefined 450 } 451 } 452 return current 453} 454 455function buildNestedObject( 456 path: string[], 457 value: unknown, 458): Record<string, unknown> { 459 if (path.length === 0) { 460 return {} 461 } 462 const key = path[0]! 463 if (path.length === 1) { 464 return { [key]: value } 465 } 466 return { [key]: buildNestedObject(path.slice(1), value) } 467}