source dump of claude code
at main 472 lines 15 kB view raw
1/** 2 * User keybinding configuration loader with hot-reload support. 3 * 4 * Loads keybindings from ~/.claude/keybindings.json and watches 5 * for changes to reload them automatically. 6 * 7 * NOTE: User keybinding customization is currently only available for 8 * Anthropic employees (USER_TYPE === 'ant'). External users always 9 * use the default bindings. 10 */ 11 12import chokidar, { type FSWatcher } from 'chokidar' 13import { readFileSync } from 'fs' 14import { readFile, stat } from 'fs/promises' 15import { dirname, join } from 'path' 16import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 17import { logEvent } from '../services/analytics/index.js' 18import { registerCleanup } from '../utils/cleanupRegistry.js' 19import { logForDebugging } from '../utils/debug.js' 20import { getClaudeConfigHomeDir } from '../utils/envUtils.js' 21import { errorMessage, isENOENT } from '../utils/errors.js' 22import { createSignal } from '../utils/signal.js' 23import { jsonParse } from '../utils/slowOperations.js' 24import { DEFAULT_BINDINGS } from './defaultBindings.js' 25import { parseBindings } from './parser.js' 26import type { KeybindingBlock, ParsedBinding } from './types.js' 27import { 28 checkDuplicateKeysInJson, 29 type KeybindingWarning, 30 validateBindings, 31} from './validate.js' 32 33/** 34 * Check if keybinding customization is enabled. 35 * 36 * Returns true if the tengu_keybinding_customization_release GrowthBook gate is enabled. 37 * 38 * This function is exported so other parts of the codebase (e.g., /doctor) 39 * can check the same condition consistently. 40 */ 41export function isKeybindingCustomizationEnabled(): boolean { 42 return getFeatureValue_CACHED_MAY_BE_STALE( 43 'tengu_keybinding_customization_release', 44 false, 45 ) 46} 47 48/** 49 * Time in milliseconds to wait for file writes to stabilize. 50 */ 51const FILE_STABILITY_THRESHOLD_MS = 500 52 53/** 54 * Polling interval for checking file stability. 55 */ 56const FILE_STABILITY_POLL_INTERVAL_MS = 200 57 58/** 59 * Result of loading keybindings, including any validation warnings. 60 */ 61export type KeybindingsLoadResult = { 62 bindings: ParsedBinding[] 63 warnings: KeybindingWarning[] 64} 65 66let watcher: FSWatcher | null = null 67let initialized = false 68let disposed = false 69let cachedBindings: ParsedBinding[] | null = null 70let cachedWarnings: KeybindingWarning[] = [] 71const keybindingsChanged = createSignal<[result: KeybindingsLoadResult]>() 72 73/** 74 * Tracks the date (YYYY-MM-DD) when we last logged a custom keybindings load event. 75 * Used to ensure we fire the event at most once per day. 76 */ 77let lastCustomBindingsLogDate: string | null = null 78 79/** 80 * Log a telemetry event when custom keybindings are loaded, at most once per day. 81 * This lets us estimate the percentage of users who customize their keybindings. 82 */ 83function logCustomBindingsLoadedOncePerDay(userBindingCount: number): void { 84 const today = new Date().toISOString().slice(0, 10) 85 if (lastCustomBindingsLogDate === today) return 86 lastCustomBindingsLogDate = today 87 logEvent('tengu_custom_keybindings_loaded', { 88 user_binding_count: userBindingCount, 89 }) 90} 91 92/** 93 * Type guard to check if an object is a valid KeybindingBlock. 94 */ 95function isKeybindingBlock(obj: unknown): obj is KeybindingBlock { 96 if (typeof obj !== 'object' || obj === null) return false 97 const b = obj as Record<string, unknown> 98 return ( 99 typeof b.context === 'string' && 100 typeof b.bindings === 'object' && 101 b.bindings !== null 102 ) 103} 104 105/** 106 * Type guard to check if an array contains only valid KeybindingBlocks. 107 */ 108function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] { 109 return Array.isArray(arr) && arr.every(isKeybindingBlock) 110} 111 112/** 113 * Get the path to the user keybindings file. 114 */ 115export function getKeybindingsPath(): string { 116 return join(getClaudeConfigHomeDir(), 'keybindings.json') 117} 118 119/** 120 * Parse default bindings (cached for performance). 121 */ 122function getDefaultParsedBindings(): ParsedBinding[] { 123 return parseBindings(DEFAULT_BINDINGS) 124} 125 126/** 127 * Load and parse keybindings from user config file. 128 * Returns merged default + user bindings along with validation warnings. 129 * 130 * For external users, always returns default bindings only. 131 * User customization is currently gated to Anthropic employees. 132 */ 133export async function loadKeybindings(): Promise<KeybindingsLoadResult> { 134 const defaultBindings = getDefaultParsedBindings() 135 136 // Skip user config loading for external users 137 if (!isKeybindingCustomizationEnabled()) { 138 return { bindings: defaultBindings, warnings: [] } 139 } 140 141 const userPath = getKeybindingsPath() 142 143 try { 144 const content = await readFile(userPath, 'utf-8') 145 const parsed: unknown = jsonParse(content) 146 147 // Extract bindings array from object wrapper format: { "bindings": [...] } 148 let userBlocks: unknown 149 if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) { 150 userBlocks = (parsed as { bindings: unknown }).bindings 151 } else { 152 // Invalid format - missing bindings property 153 const errorMessage = 'keybindings.json must have a "bindings" array' 154 const suggestion = 'Use format: { "bindings": [ ... ] }' 155 logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`) 156 return { 157 bindings: defaultBindings, 158 warnings: [ 159 { 160 type: 'parse_error', 161 severity: 'error', 162 message: errorMessage, 163 suggestion, 164 }, 165 ], 166 } 167 } 168 169 // Validate structure - bindings must be an array of valid keybinding blocks 170 if (!isKeybindingBlockArray(userBlocks)) { 171 const errorMessage = !Array.isArray(userBlocks) 172 ? '"bindings" must be an array' 173 : 'keybindings.json contains invalid block structure' 174 const suggestion = !Array.isArray(userBlocks) 175 ? 'Set "bindings" to an array of keybinding blocks' 176 : 'Each block must have "context" (string) and "bindings" (object)' 177 logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`) 178 return { 179 bindings: defaultBindings, 180 warnings: [ 181 { 182 type: 'parse_error', 183 severity: 'error', 184 message: errorMessage, 185 suggestion, 186 }, 187 ], 188 } 189 } 190 191 const userParsed = parseBindings(userBlocks) 192 logForDebugging( 193 `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`, 194 ) 195 196 // User bindings come after defaults, so they override 197 const mergedBindings = [...defaultBindings, ...userParsed] 198 199 logCustomBindingsLoadedOncePerDay(userParsed.length) 200 201 // Run validation on user config 202 // First check for duplicate keys in raw JSON (JSON.parse silently drops earlier values) 203 const duplicateKeyWarnings = checkDuplicateKeysInJson(content) 204 const warnings = [ 205 ...duplicateKeyWarnings, 206 ...validateBindings(userBlocks, mergedBindings), 207 ] 208 209 if (warnings.length > 0) { 210 logForDebugging( 211 `[keybindings] Found ${warnings.length} validation issue(s)`, 212 ) 213 } 214 215 return { bindings: mergedBindings, warnings } 216 } catch (error) { 217 // File doesn't exist - use defaults (user can run /keybindings to create) 218 if (isENOENT(error)) { 219 return { bindings: defaultBindings, warnings: [] } 220 } 221 222 // Other error - log and return defaults with warning 223 logForDebugging( 224 `[keybindings] Error loading ${userPath}: ${errorMessage(error)}`, 225 ) 226 return { 227 bindings: defaultBindings, 228 warnings: [ 229 { 230 type: 'parse_error', 231 severity: 'error', 232 message: `Failed to parse keybindings.json: ${errorMessage(error)}`, 233 }, 234 ], 235 } 236 } 237} 238 239/** 240 * Load keybindings synchronously (for initial render). 241 * Uses cached value if available. 242 */ 243export function loadKeybindingsSync(): ParsedBinding[] { 244 if (cachedBindings) { 245 return cachedBindings 246 } 247 248 const result = loadKeybindingsSyncWithWarnings() 249 return result.bindings 250} 251 252/** 253 * Load keybindings synchronously with validation warnings. 254 * Uses cached values if available. 255 * 256 * For external users, always returns default bindings only. 257 * User customization is currently gated to Anthropic employees. 258 */ 259export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult { 260 if (cachedBindings) { 261 return { bindings: cachedBindings, warnings: cachedWarnings } 262 } 263 264 const defaultBindings = getDefaultParsedBindings() 265 266 // Skip user config loading for external users 267 if (!isKeybindingCustomizationEnabled()) { 268 cachedBindings = defaultBindings 269 cachedWarnings = [] 270 return { bindings: cachedBindings, warnings: cachedWarnings } 271 } 272 273 const userPath = getKeybindingsPath() 274 275 try { 276 // sync IO: called from sync context (React useState initializer) 277 const content = readFileSync(userPath, 'utf-8') 278 const parsed: unknown = jsonParse(content) 279 280 // Extract bindings array from object wrapper format: { "bindings": [...] } 281 let userBlocks: unknown 282 if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) { 283 userBlocks = (parsed as { bindings: unknown }).bindings 284 } else { 285 // Invalid format - missing bindings property 286 cachedBindings = defaultBindings 287 cachedWarnings = [ 288 { 289 type: 'parse_error', 290 severity: 'error', 291 message: 'keybindings.json must have a "bindings" array', 292 suggestion: 'Use format: { "bindings": [ ... ] }', 293 }, 294 ] 295 return { bindings: cachedBindings, warnings: cachedWarnings } 296 } 297 298 // Validate structure - bindings must be an array of valid keybinding blocks 299 if (!isKeybindingBlockArray(userBlocks)) { 300 const errorMessage = !Array.isArray(userBlocks) 301 ? '"bindings" must be an array' 302 : 'keybindings.json contains invalid block structure' 303 const suggestion = !Array.isArray(userBlocks) 304 ? 'Set "bindings" to an array of keybinding blocks' 305 : 'Each block must have "context" (string) and "bindings" (object)' 306 cachedBindings = defaultBindings 307 cachedWarnings = [ 308 { 309 type: 'parse_error', 310 severity: 'error', 311 message: errorMessage, 312 suggestion, 313 }, 314 ] 315 return { bindings: cachedBindings, warnings: cachedWarnings } 316 } 317 318 const userParsed = parseBindings(userBlocks) 319 logForDebugging( 320 `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`, 321 ) 322 cachedBindings = [...defaultBindings, ...userParsed] 323 324 logCustomBindingsLoadedOncePerDay(userParsed.length) 325 326 // Run validation - check for duplicate keys in raw JSON first 327 const duplicateKeyWarnings = checkDuplicateKeysInJson(content) 328 cachedWarnings = [ 329 ...duplicateKeyWarnings, 330 ...validateBindings(userBlocks, cachedBindings), 331 ] 332 if (cachedWarnings.length > 0) { 333 logForDebugging( 334 `[keybindings] Found ${cachedWarnings.length} validation issue(s)`, 335 ) 336 } 337 338 return { bindings: cachedBindings, warnings: cachedWarnings } 339 } catch { 340 // File doesn't exist or error - use defaults (user can run /keybindings to create) 341 cachedBindings = defaultBindings 342 cachedWarnings = [] 343 return { bindings: cachedBindings, warnings: cachedWarnings } 344 } 345} 346 347/** 348 * Initialize file watching for keybindings.json. 349 * Call this once when the app starts. 350 * 351 * For external users, this is a no-op since user customization is disabled. 352 */ 353export async function initializeKeybindingWatcher(): Promise<void> { 354 if (initialized || disposed) return 355 356 // Skip file watching for external users 357 if (!isKeybindingCustomizationEnabled()) { 358 logForDebugging( 359 '[keybindings] Skipping file watcher - user customization disabled', 360 ) 361 return 362 } 363 364 const userPath = getKeybindingsPath() 365 const watchDir = dirname(userPath) 366 367 // Only watch if parent directory exists 368 try { 369 const stats = await stat(watchDir) 370 if (!stats.isDirectory()) { 371 logForDebugging( 372 `[keybindings] Not watching: ${watchDir} is not a directory`, 373 ) 374 return 375 } 376 } catch { 377 logForDebugging(`[keybindings] Not watching: ${watchDir} does not exist`) 378 return 379 } 380 381 // Set initialized only after we've confirmed we can watch 382 initialized = true 383 384 logForDebugging(`[keybindings] Watching for changes to ${userPath}`) 385 386 watcher = chokidar.watch(userPath, { 387 persistent: true, 388 ignoreInitial: true, 389 awaitWriteFinish: { 390 stabilityThreshold: FILE_STABILITY_THRESHOLD_MS, 391 pollInterval: FILE_STABILITY_POLL_INTERVAL_MS, 392 }, 393 ignorePermissionErrors: true, 394 usePolling: false, 395 atomic: true, 396 }) 397 398 watcher.on('add', handleChange) 399 watcher.on('change', handleChange) 400 watcher.on('unlink', handleDelete) 401 402 // Register cleanup 403 registerCleanup(async () => disposeKeybindingWatcher()) 404} 405 406/** 407 * Clean up the file watcher. 408 */ 409export function disposeKeybindingWatcher(): void { 410 disposed = true 411 if (watcher) { 412 void watcher.close() 413 watcher = null 414 } 415 keybindingsChanged.clear() 416} 417 418/** 419 * Subscribe to keybinding changes. 420 * The listener receives the new parsed bindings when the file changes. 421 */ 422export const subscribeToKeybindingChanges = keybindingsChanged.subscribe 423 424async function handleChange(path: string): Promise<void> { 425 logForDebugging(`[keybindings] Detected change to ${path}`) 426 427 try { 428 const result = await loadKeybindings() 429 cachedBindings = result.bindings 430 cachedWarnings = result.warnings 431 432 // Notify all listeners with the full result 433 keybindingsChanged.emit(result) 434 } catch (error) { 435 logForDebugging(`[keybindings] Error reloading: ${errorMessage(error)}`) 436 } 437} 438 439function handleDelete(path: string): void { 440 logForDebugging(`[keybindings] Detected deletion of ${path}`) 441 442 // Reset to defaults when file is deleted 443 const defaultBindings = getDefaultParsedBindings() 444 cachedBindings = defaultBindings 445 cachedWarnings = [] 446 447 keybindingsChanged.emit({ bindings: defaultBindings, warnings: [] }) 448} 449 450/** 451 * Get the cached keybinding warnings. 452 * Returns empty array if no warnings or bindings haven't been loaded yet. 453 */ 454export function getCachedKeybindingWarnings(): KeybindingWarning[] { 455 return cachedWarnings 456} 457 458/** 459 * Reset internal state for testing. 460 */ 461export function resetKeybindingLoaderForTesting(): void { 462 initialized = false 463 disposed = false 464 cachedBindings = null 465 cachedWarnings = [] 466 lastCustomBindingsLogDate = null 467 if (watcher) { 468 void watcher.close() 469 watcher = null 470 } 471 keybindingsChanged.clear() 472}