init
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
/**
|
||||
* 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 = null
|
||||
let initialized = false
|
||||
let disposed = false
|
||||
let cachedBindings: ParsedBinding[] | null = null
|
||||
let 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()
|
||||
}
|
||||
Reference in New Issue
Block a user