mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 15:10:59 -07:00
260 lines
6.6 KiB
TypeScript
260 lines
6.6 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type { Config } from '../config/config.js';
|
|
import type { HookDefinition, HookConfig } from './types.js';
|
|
import { HookEventName, ConfigSource } from './types.js';
|
|
import { debugLogger } from '../utils/debugLogger.js';
|
|
|
|
/**
|
|
* Hook registry entry with source information
|
|
*/
|
|
export interface HookRegistryEntry {
|
|
config: HookConfig;
|
|
source: ConfigSource;
|
|
eventName: HookEventName;
|
|
matcher?: string;
|
|
sequential?: boolean;
|
|
enabled: boolean;
|
|
}
|
|
|
|
/**
|
|
* Hook registry that loads and validates hook definitions from multiple sources
|
|
*/
|
|
export class HookRegistry {
|
|
private readonly config: Config;
|
|
private entries: HookRegistryEntry[] = [];
|
|
|
|
constructor(config: Config) {
|
|
this.config = config;
|
|
}
|
|
|
|
/**
|
|
* Initialize the registry by processing hooks from config
|
|
*/
|
|
async initialize(): Promise<void> {
|
|
this.entries = [];
|
|
this.processHooksFromConfig();
|
|
|
|
debugLogger.log(
|
|
`Hook registry initialized with ${this.entries.length} hook entries`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all hook entries for a specific event
|
|
*/
|
|
getHooksForEvent(eventName: HookEventName): HookRegistryEntry[] {
|
|
return this.entries
|
|
.filter((entry) => entry.eventName === eventName && entry.enabled)
|
|
.sort(
|
|
(a, b) =>
|
|
this.getSourcePriority(a.source) - this.getSourcePriority(b.source),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all registered hooks
|
|
*/
|
|
getAllHooks(): HookRegistryEntry[] {
|
|
return [...this.entries];
|
|
}
|
|
|
|
/**
|
|
* Enable or disable a specific hook
|
|
*/
|
|
setHookEnabled(hookName: string, enabled: boolean): void {
|
|
const updated = this.entries.filter((entry) => {
|
|
const name = this.getHookName(entry);
|
|
if (name === hookName) {
|
|
entry.enabled = enabled;
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
|
|
if (updated.length > 0) {
|
|
debugLogger.log(
|
|
`${enabled ? 'Enabled' : 'Disabled'} ${updated.length} hook(s) matching "${hookName}"`,
|
|
);
|
|
} else {
|
|
debugLogger.warn(`No hooks found matching "${hookName}"`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get hook name for identification and display purposes
|
|
*/
|
|
private getHookName(
|
|
entry: HookRegistryEntry | { config: HookConfig },
|
|
): string {
|
|
return entry.config.name || entry.config.command || 'unknown-command';
|
|
}
|
|
|
|
/**
|
|
* Process hooks from the config that was already loaded by the CLI
|
|
*/
|
|
private processHooksFromConfig(): void {
|
|
// Get hooks from the main config (this comes from the merged settings)
|
|
const configHooks = this.config.getHooks();
|
|
if (configHooks) {
|
|
if (this.config.isTrustedFolder()) {
|
|
this.processHooksConfiguration(configHooks, ConfigSource.Project);
|
|
} else {
|
|
debugLogger.warn(
|
|
'Project hooks disabled because the folder is not trusted.',
|
|
);
|
|
}
|
|
}
|
|
|
|
// Get hooks from extensions
|
|
const extensions = this.config.getExtensions() || [];
|
|
for (const extension of extensions) {
|
|
if (extension.isActive && extension.hooks) {
|
|
this.processHooksConfiguration(
|
|
extension.hooks,
|
|
ConfigSource.Extensions,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process hooks configuration and add entries
|
|
*/
|
|
private processHooksConfiguration(
|
|
hooksConfig: { [K in HookEventName]?: HookDefinition[] },
|
|
source: ConfigSource,
|
|
): void {
|
|
for (const [eventName, definitions] of Object.entries(hooksConfig)) {
|
|
if (!this.isValidEventName(eventName)) {
|
|
debugLogger.warn(`Invalid hook event name: ${eventName}`);
|
|
continue;
|
|
}
|
|
|
|
const typedEventName = eventName;
|
|
|
|
if (!Array.isArray(definitions)) {
|
|
debugLogger.warn(
|
|
`Hook definitions for event "${eventName}" from source "${source}" is not an array. Skipping.`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
for (const definition of definitions) {
|
|
this.processHookDefinition(definition, typedEventName, source);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a single hook definition
|
|
*/
|
|
private processHookDefinition(
|
|
definition: HookDefinition,
|
|
eventName: HookEventName,
|
|
source: ConfigSource,
|
|
): void {
|
|
if (
|
|
!definition ||
|
|
typeof definition !== 'object' ||
|
|
!Array.isArray(definition.hooks)
|
|
) {
|
|
debugLogger.warn(
|
|
`Discarding invalid hook definition for ${eventName} from ${source}:`,
|
|
definition,
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Get disabled hooks list from settings
|
|
const disabledHooks = this.config.getDisabledHooks() || [];
|
|
|
|
for (const hookConfig of definition.hooks) {
|
|
if (
|
|
hookConfig &&
|
|
typeof hookConfig === 'object' &&
|
|
this.validateHookConfig(hookConfig, eventName, source)
|
|
) {
|
|
// Check if this hook is in the disabled list
|
|
const hookName = this.getHookName({
|
|
config: hookConfig,
|
|
} as HookRegistryEntry);
|
|
const isDisabled = disabledHooks.includes(hookName);
|
|
|
|
// Add source to hook config
|
|
hookConfig.source = source;
|
|
|
|
this.entries.push({
|
|
config: hookConfig,
|
|
source,
|
|
eventName,
|
|
matcher: definition.matcher,
|
|
sequential: definition.sequential,
|
|
enabled: !isDisabled,
|
|
});
|
|
} else {
|
|
// Invalid hooks are logged and discarded here, they won't reach HookRunner
|
|
debugLogger.warn(
|
|
`Discarding invalid hook configuration for ${eventName} from ${source}:`,
|
|
hookConfig,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validate a hook configuration
|
|
*/
|
|
private validateHookConfig(
|
|
config: HookConfig,
|
|
eventName: HookEventName,
|
|
source: ConfigSource,
|
|
): boolean {
|
|
if (!config.type || !['command', 'plugin'].includes(config.type)) {
|
|
debugLogger.warn(
|
|
`Invalid hook ${eventName} from ${source} type: ${config.type}`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
if (config.type === 'command' && !config.command) {
|
|
debugLogger.warn(
|
|
`Command hook ${eventName} from ${source} missing command field`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Check if an event name is valid
|
|
*/
|
|
private isValidEventName(eventName: string): eventName is HookEventName {
|
|
const validEventNames = Object.values(HookEventName);
|
|
return validEventNames.includes(eventName as HookEventName);
|
|
}
|
|
|
|
/**
|
|
* Get source priority (lower number = higher priority)
|
|
*/
|
|
private getSourcePriority(source: ConfigSource): number {
|
|
switch (source) {
|
|
case ConfigSource.Project:
|
|
return 1;
|
|
case ConfigSource.User:
|
|
return 2;
|
|
case ConfigSource.System:
|
|
return 3;
|
|
case ConfigSource.Extensions:
|
|
return 4;
|
|
default:
|
|
return 999;
|
|
}
|
|
}
|
|
}
|