/ keybindings / loadUserBindings.ts
loadUserBindings.ts
  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  
 12  import chokidar, { type FSWatcher } from 'chokidar'
 13  import { readFileSync } from 'fs'
 14  import { readFile, stat } from 'fs/promises'
 15  import { dirname, join } from 'path'
 16  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
 17  import { logEvent } from '../services/analytics/index.js'
 18  import { registerCleanup } from '../utils/cleanupRegistry.js'
 19  import { logForDebugging } from '../utils/debug.js'
 20  import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
 21  import { errorMessage, isENOENT } from '../utils/errors.js'
 22  import { createSignal } from '../utils/signal.js'
 23  import { jsonParse } from '../utils/slowOperations.js'
 24  import { DEFAULT_BINDINGS } from './defaultBindings.js'
 25  import { parseBindings } from './parser.js'
 26  import type { KeybindingBlock, ParsedBinding } from './types.js'
 27  import {
 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   */
 41  export 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   */
 51  const FILE_STABILITY_THRESHOLD_MS = 500
 52  
 53  /**
 54   * Polling interval for checking file stability.
 55   */
 56  const FILE_STABILITY_POLL_INTERVAL_MS = 200
 57  
 58  /**
 59   * Result of loading keybindings, including any validation warnings.
 60   */
 61  export type KeybindingsLoadResult = {
 62    bindings: ParsedBinding[]
 63    warnings: KeybindingWarning[]
 64  }
 65  
 66  let watcher: FSWatcher | null = null
 67  let initialized = false
 68  let disposed = false
 69  let cachedBindings: ParsedBinding[] | null = null
 70  let cachedWarnings: KeybindingWarning[] = []
 71  const 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   */
 77  let 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   */
 83  function 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   */
 95  function 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   */
108  function 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   */
115  export function getKeybindingsPath(): string {
116    return join(getClaudeConfigHomeDir(), 'keybindings.json')
117  }
118  
119  /**
120   * Parse default bindings (cached for performance).
121   */
122  function 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   */
133  export 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   */
243  export 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   */
259  export 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   */
353  export 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   */
409  export 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   */
422  export const subscribeToKeybindingChanges = keybindingsChanged.subscribe
423  
424  async 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  
439  function 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   */
454  export function getCachedKeybindingWarnings(): KeybindingWarning[] {
455    return cachedWarnings
456  }
457  
458  /**
459   * Reset internal state for testing.
460   */
461  export 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  }