Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
15 kB · 472 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473/** * User keybinding configuration loader with hot-reload support. * * Loads keybindings from ~/.claude/keybindings.json and watches * for changes to reload them automatically. * * NOTE: User keybinding customization is currently only available for * Anthropic employees (USER_TYPE === 'ant'). External users always * use the default bindings. */
import chokidar, { type FSWatcher } from 'chokidar'import { readFileSync } from 'fs'import { readFile, stat } from 'fs/promises'import { dirname, join } from 'path'import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'import { logEvent } from '../services/analytics/index.js'import { registerCleanup } from '../utils/cleanupRegistry.js'import { logForDebugging } from '../utils/debug.js'import { getClaudeConfigHomeDir } from '../utils/envUtils.js'import { errorMessage, isENOENT } from '../utils/errors.js'import { createSignal } from '../utils/signal.js'import { jsonParse } from '../utils/slowOperations.js'import { DEFAULT_BINDINGS } from './defaultBindings.js'import { parseBindings } from './parser.js'import type { KeybindingBlock, ParsedBinding } from './types.js'import { checkDuplicateKeysInJson, type KeybindingWarning, validateBindings,} from './validate.js'
/** * Check if keybinding customization is enabled. * * Returns true if the tengu_keybinding_customization_release GrowthBook gate is enabled. * * This function is exported so other parts of the codebase (e.g., /doctor) * can check the same condition consistently. */export function isKeybindingCustomizationEnabled(): boolean { return getFeatureValue_CACHED_MAY_BE_STALE( 'tengu_keybinding_customization_release', false, )}
/** * Time in milliseconds to wait for file writes to stabilize. */const FILE_STABILITY_THRESHOLD_MS = 500
/** * Polling interval for checking file stability. */const FILE_STABILITY_POLL_INTERVAL_MS = 200
/** * Result of loading keybindings, including any validation warnings. */export type KeybindingsLoadResult = { bindings: ParsedBinding[] warnings: KeybindingWarning[]}
let watcher: FSWatcher | null = nulllet initialized = falselet disposed = falselet cachedBindings: ParsedBinding[] | null = nulllet cachedWarnings: KeybindingWarning[] = []const keybindingsChanged = createSignal<[result: KeybindingsLoadResult]>()
/** * Tracks the date (YYYY-MM-DD) when we last logged a custom keybindings load event. * Used to ensure we fire the event at most once per day. */let lastCustomBindingsLogDate: string | null = null
/** * Log a telemetry event when custom keybindings are loaded, at most once per day. * This lets us estimate the percentage of users who customize their keybindings. */function logCustomBindingsLoadedOncePerDay(userBindingCount: number): void { const today = new Date().toISOString().slice(0, 10) if (lastCustomBindingsLogDate === today) return lastCustomBindingsLogDate = today logEvent('tengu_custom_keybindings_loaded', { user_binding_count: userBindingCount, })}
/** * Type guard to check if an object is a valid KeybindingBlock. */function isKeybindingBlock(obj: unknown): obj is KeybindingBlock { if (typeof obj !== 'object' || obj === null) return false const b = obj as Record<string, unknown> return ( typeof b.context === 'string' && typeof b.bindings === 'object' && b.bindings !== null )}
/** * Type guard to check if an array contains only valid KeybindingBlocks. */function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] { return Array.isArray(arr) && arr.every(isKeybindingBlock)}
/** * Get the path to the user keybindings file. */export function getKeybindingsPath(): string { return join(getClaudeConfigHomeDir(), 'keybindings.json')}
/** * Parse default bindings (cached for performance). */function getDefaultParsedBindings(): ParsedBinding[] { return parseBindings(DEFAULT_BINDINGS)}
/** * Load and parse keybindings from user config file. * Returns merged default + user bindings along with validation warnings. * * For external users, always returns default bindings only. * User customization is currently gated to Anthropic employees. */export async function loadKeybindings(): Promise<KeybindingsLoadResult> { const defaultBindings = getDefaultParsedBindings()
// Skip user config loading for external users if (!isKeybindingCustomizationEnabled()) { return { bindings: defaultBindings, warnings: [] } }
const userPath = getKeybindingsPath()
try { const content = await readFile(userPath, 'utf-8') const parsed: unknown = jsonParse(content)
// Extract bindings array from object wrapper format: { "bindings": [...] } let userBlocks: unknown if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) { userBlocks = (parsed as { bindings: unknown }).bindings } else { // Invalid format - missing bindings property const errorMessage = 'keybindings.json must have a "bindings" array' const suggestion = 'Use format: { "bindings": [ ... ] }' logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`) return { bindings: defaultBindings, warnings: [ { type: 'parse_error', severity: 'error', message: errorMessage, suggestion, }, ], } }
// Validate structure - bindings must be an array of valid keybinding blocks if (!isKeybindingBlockArray(userBlocks)) { const errorMessage = !Array.isArray(userBlocks) ? '"bindings" must be an array' : 'keybindings.json contains invalid block structure' const suggestion = !Array.isArray(userBlocks) ? 'Set "bindings" to an array of keybinding blocks' : 'Each block must have "context" (string) and "bindings" (object)' logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`) return { bindings: defaultBindings, warnings: [ { type: 'parse_error', severity: 'error', message: errorMessage, suggestion, }, ], } }
const userParsed = parseBindings(userBlocks) logForDebugging( `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`, )
// User bindings come after defaults, so they override const mergedBindings = [...defaultBindings, ...userParsed]
logCustomBindingsLoadedOncePerDay(userParsed.length)
// Run validation on user config // First check for duplicate keys in raw JSON (JSON.parse silently drops earlier values) const duplicateKeyWarnings = checkDuplicateKeysInJson(content) const warnings = [ ...duplicateKeyWarnings, ...validateBindings(userBlocks, mergedBindings), ]
if (warnings.length > 0) { logForDebugging( `[keybindings] Found ${warnings.length} validation issue(s)`, ) }
return { bindings: mergedBindings, warnings } } catch (error) { // File doesn't exist - use defaults (user can run /keybindings to create) if (isENOENT(error)) { return { bindings: defaultBindings, warnings: [] } }
// Other error - log and return defaults with warning logForDebugging( `[keybindings] Error loading ${userPath}: ${errorMessage(error)}`, ) return { bindings: defaultBindings, warnings: [ { type: 'parse_error', severity: 'error', message: `Failed to parse keybindings.json: ${errorMessage(error)}`, }, ], } }}
/** * Load keybindings synchronously (for initial render). * Uses cached value if available. */export function loadKeybindingsSync(): ParsedBinding[] { if (cachedBindings) { return cachedBindings }
const result = loadKeybindingsSyncWithWarnings() return result.bindings}
/** * Load keybindings synchronously with validation warnings. * Uses cached values if available. * * For external users, always returns default bindings only. * User customization is currently gated to Anthropic employees. */export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult { if (cachedBindings) { return { bindings: cachedBindings, warnings: cachedWarnings } }
const defaultBindings = getDefaultParsedBindings()
// Skip user config loading for external users if (!isKeybindingCustomizationEnabled()) { cachedBindings = defaultBindings cachedWarnings = [] return { bindings: cachedBindings, warnings: cachedWarnings } }
const userPath = getKeybindingsPath()
try { // sync IO: called from sync context (React useState initializer) const content = readFileSync(userPath, 'utf-8') const parsed: unknown = jsonParse(content)
// Extract bindings array from object wrapper format: { "bindings": [...] } let userBlocks: unknown if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) { userBlocks = (parsed as { bindings: unknown }).bindings } else { // Invalid format - missing bindings property cachedBindings = defaultBindings cachedWarnings = [ { type: 'parse_error', severity: 'error', message: 'keybindings.json must have a "bindings" array', suggestion: 'Use format: { "bindings": [ ... ] }', }, ] return { bindings: cachedBindings, warnings: cachedWarnings } }
// Validate structure - bindings must be an array of valid keybinding blocks if (!isKeybindingBlockArray(userBlocks)) { const errorMessage = !Array.isArray(userBlocks) ? '"bindings" must be an array' : 'keybindings.json contains invalid block structure' const suggestion = !Array.isArray(userBlocks) ? 'Set "bindings" to an array of keybinding blocks' : 'Each block must have "context" (string) and "bindings" (object)' cachedBindings = defaultBindings cachedWarnings = [ { type: 'parse_error', severity: 'error', message: errorMessage, suggestion, }, ] return { bindings: cachedBindings, warnings: cachedWarnings } }
const userParsed = parseBindings(userBlocks) logForDebugging( `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`, ) cachedBindings = [...defaultBindings, ...userParsed]
logCustomBindingsLoadedOncePerDay(userParsed.length)
// Run validation - check for duplicate keys in raw JSON first const duplicateKeyWarnings = checkDuplicateKeysInJson(content) cachedWarnings = [ ...duplicateKeyWarnings, ...validateBindings(userBlocks, cachedBindings), ] if (cachedWarnings.length > 0) { logForDebugging( `[keybindings] Found ${cachedWarnings.length} validation issue(s)`, ) }
return { bindings: cachedBindings, warnings: cachedWarnings } } catch { // File doesn't exist or error - use defaults (user can run /keybindings to create) cachedBindings = defaultBindings cachedWarnings = [] return { bindings: cachedBindings, warnings: cachedWarnings } }}
/** * Initialize file watching for keybindings.json. * Call this once when the app starts. * * For external users, this is a no-op since user customization is disabled. */export async function initializeKeybindingWatcher(): Promise<void> { if (initialized || disposed) return
// Skip file watching for external users if (!isKeybindingCustomizationEnabled()) { logForDebugging( '[keybindings] Skipping file watcher - user customization disabled', ) return }
const userPath = getKeybindingsPath() const watchDir = dirname(userPath)
// Only watch if parent directory exists try { const stats = await stat(watchDir) if (!stats.isDirectory()) { logForDebugging( `[keybindings] Not watching: ${watchDir} is not a directory`, ) return } } catch { logForDebugging(`[keybindings] Not watching: ${watchDir} does not exist`) return }
// Set initialized only after we've confirmed we can watch initialized = true
logForDebugging(`[keybindings] Watching for changes to ${userPath}`)
watcher = chokidar.watch(userPath, { persistent: true, ignoreInitial: true, awaitWriteFinish: { stabilityThreshold: FILE_STABILITY_THRESHOLD_MS, pollInterval: FILE_STABILITY_POLL_INTERVAL_MS, }, ignorePermissionErrors: true, usePolling: false, atomic: true, })
watcher.on('add', handleChange) watcher.on('change', handleChange) watcher.on('unlink', handleDelete)
// Register cleanup registerCleanup(async () => disposeKeybindingWatcher())}
/** * Clean up the file watcher. */export function disposeKeybindingWatcher(): void { disposed = true if (watcher) { void watcher.close() watcher = null } keybindingsChanged.clear()}
/** * Subscribe to keybinding changes. * The listener receives the new parsed bindings when the file changes. */export const subscribeToKeybindingChanges = keybindingsChanged.subscribe
async function handleChange(path: string): Promise<void> { logForDebugging(`[keybindings] Detected change to ${path}`)
try { const result = await loadKeybindings() cachedBindings = result.bindings cachedWarnings = result.warnings
// Notify all listeners with the full result keybindingsChanged.emit(result) } catch (error) { logForDebugging(`[keybindings] Error reloading: ${errorMessage(error)}`) }}
function handleDelete(path: string): void { logForDebugging(`[keybindings] Detected deletion of ${path}`)
// Reset to defaults when file is deleted const defaultBindings = getDefaultParsedBindings() cachedBindings = defaultBindings cachedWarnings = []
keybindingsChanged.emit({ bindings: defaultBindings, warnings: [] })}
/** * Get the cached keybinding warnings. * Returns empty array if no warnings or bindings haven't been loaded yet. */export function getCachedKeybindingWarnings(): KeybindingWarning[] { return cachedWarnings}
/** * Reset internal state for testing. */export function resetKeybindingLoaderForTesting(): void { initialized = false disposed = false cachedBindings = null cachedWarnings = [] lastCustomBindingsLogDate = null if (watcher) { void watcher.close() watcher = null } keybindingsChanged.clear()}