From 2b8557574c6cd3d9ac463e9c2607d47ce935f718 Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Tue, 24 Mar 2026 12:24:54 -0700 Subject: [PATCH] feat: implement Open Plugins hooks support - Implement discovery of hooks from plugin.json and hooks/hooks.json at the plugin root - Add support for ${PLUGIN_ROOT} variable expansion in hook command strings - Implement Open Plugin protocol for hook execution, including JSON communication over stdin/stdout - Add OpenPluginTranslator to map Gemini internal events to standard Open Plugin events (e.g., BeforeTool -> onTool) - Translate Open Plugin hook responses (e.g., allow: false) to Gemini hook decisions (e.g., decision: 'block') - Inject PLUGIN_ROOT into the environment for hook child processes - Include plugin_name and plugin_root in the HookInput passed to Open Plugin hooks - Align ExtensionConfig and GeminiCLIExtension with Open Plugin metadata (repository, homepage, etc.) - Refactor for type safety and cleaner ESLint compliance Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1593 --- packages/cli/src/config/extension.ts | 2 + packages/cli/src/config/plugin.ts | 174 +++++++++++++++--- packages/core/src/hooks/hookRunner.ts | 22 ++- packages/core/src/hooks/index.ts | 3 + .../core/src/hooks/openPluginTranslator.ts | 69 +++++++ packages/core/src/hooks/types.ts | 4 + 6 files changed, 248 insertions(+), 26 deletions(-) create mode 100644 packages/core/src/hooks/openPluginTranslator.ts diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 55d19776ca..ff3e107df6 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -41,6 +41,7 @@ export interface ExtensionConfig { contextFileName?: string | string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; + hooks?: Record; /** * Custom themes contributed by this extension. * These themes will be registered when the extension is activated. @@ -80,6 +81,7 @@ export const geminiExtensionSchema = z.object({ contextFileName: z.union([z.string(), z.array(z.string())]).optional(), excludeTools: z.array(z.string()).optional(), settings: z.array(z.any()).optional(), + hooks: z.record(z.unknown()).optional(), themes: z.array(z.any()).optional(), plan: z .object({ diff --git a/packages/cli/src/config/plugin.ts b/packages/cli/src/config/plugin.ts index bb55987859..148800bffb 100644 --- a/packages/cli/src/config/plugin.ts +++ b/packages/cli/src/config/plugin.ts @@ -10,9 +10,13 @@ import { z } from 'zod'; import { loadSkillsFromDir, loadAgentsFromDirectory, + OPEN_PLUGIN_EVENT_MAP, + HookType, + ConfigSource, type ExtensionInstallMetadata, type GeminiCLIExtension, type MCPServerConfig, + type HookConfig, } from '@google/gemini-cli-core'; import { EXTENSIONS_CONFIG_FILENAME, @@ -21,7 +25,6 @@ import { OPEN_PLUGIN_MCP_CONFIG_FILENAME, HIDDEN_OPEN_PLUGIN_MCP_CONFIG_FILENAME, recursivelyHydrateStrings, - type JsonObject, } from './extensions/variables.js'; import type { ExtensionConfig } from './extension.js'; @@ -116,18 +119,13 @@ export async function loadOpenPluginConfig( const rawConfig = result.data as OpenPluginConfig; // Hydrate metadata fields - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const hydratedConfig = recursivelyHydrateStrings( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - rawConfig as unknown as JsonObject, - { - extensionPath: extensionDir, - PLUGIN_ROOT: extensionDir, - workspacePath: workspaceDir, - '/': path.sep, - pathSeparator: path.sep, - }, - ) as unknown as OpenPluginConfig; + const hydratedConfig = recursivelyHydrateStrings(rawConfig, { + extensionPath: extensionDir, + PLUGIN_ROOT: extensionDir, + workspacePath: workspaceDir, + '/': path.sep, + pathSeparator: path.sep, + }); const mcpServers = await resolveMcpServers(hydratedConfig, extensionDir); @@ -139,6 +137,8 @@ export async function loadOpenPluginConfig( author: hydratedConfig.author, license: hydratedConfig.license, mcpServers, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + hooks: hydratedConfig.hooks as Record | undefined, }; } @@ -153,18 +153,18 @@ async function resolveMcpServers( let mcpServers: Record | undefined; // 1. Explicit mcpServers in plugin.json - if (hydratedConfig.mcpServers) { - if (typeof hydratedConfig.mcpServers === 'string') { - const mcpPath = path.resolve(extensionDir, hydratedConfig.mcpServers); + const rawMcpServers = hydratedConfig.mcpServers; + if (rawMcpServers) { + if (typeof rawMcpServers === 'string') { + const mcpPath = path.resolve(extensionDir, rawMcpServers); mcpServers = await loadMcpConfigFile(mcpPath); - } else if (Array.isArray(hydratedConfig.mcpServers)) { - const mcpServersValue = hydratedConfig.mcpServers; - if (mcpServersValue.length > 0) { - const first = mcpServersValue[0]; + } else if (Array.isArray(rawMcpServers)) { + if (rawMcpServers.length > 0) { + const first = rawMcpServers[0]; if (typeof first === 'string') { // Support array of paths mcpServers = {}; - for (const p of mcpServersValue) { + for (const p of rawMcpServers) { const mcpPath = path.resolve(extensionDir, p); const servers = await loadMcpConfigFile(mcpPath); if (servers) { @@ -176,7 +176,7 @@ async function resolveMcpServers( } else { // It's a Record // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - mcpServers = hydratedConfig.mcpServers as Record; + mcpServers = rawMcpServers as Record; } } @@ -206,7 +206,6 @@ async function loadMcpConfigFile( const json = JSON.parse(content) as unknown; const result = openPluginMcpSchema.safeParse(json); if (result.success) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion return result.data.mcpServers as Record; } } catch (_e) { @@ -253,6 +252,8 @@ export async function createOpenPlugin( hydrationContext, ); + const hooks = await resolvePluginHooks(pluginDir, config, hydrationContext); + return { name: config.name, version: config.version, @@ -272,6 +273,7 @@ export async function createOpenPlugin( resolvedSettings: undefined, skills, agents, + hooks, themes: undefined, }; } @@ -319,3 +321,129 @@ async function resolvePluginAgents( extensionName: pluginName, })); } + +/** + * Discovers hooks for an Open Plugin. + */ +async function resolvePluginHooks( + pluginDir: string, + config: ExtensionConfig, + hydrationContext: Record, +): Promise { + let hooksSource: Record | undefined; + + // 1. Check for hooks in manifest (plugin.json) + const hooks = config.hooks; + if (hooks) { + if (typeof hooks === 'string') { + const hooksPath = path.resolve(pluginDir, hooks); + hooksSource = await loadHooksConfigFile(hooksPath); + } else if (Array.isArray(hooks)) { + if (hooks.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const firstHook = hooks[0]; + if (typeof firstHook === 'string') { + const hooksPath = path.resolve(pluginDir, firstHook); + hooksSource = await loadHooksConfigFile(hooksPath); + } + } + } else if (hooks && typeof hooks === 'object') { + hooksSource = hooks; + } + } + + // 2. Fallback to hooks/hooks.json at plugin root + if (!hooksSource) { + const defaultHooksPath = path.join(pluginDir, 'hooks', 'hooks.json'); + if (fs.existsSync(defaultHooksPath)) { + hooksSource = await loadHooksConfigFile(defaultHooksPath); + } + } + + if (!hooksSource) { + return undefined; + } + + // 3. Map Open Plugin hooks to Gemini CLI hook definitions + const result: Record> = {}; + + for (const [opEventName, hookDef] of Object.entries(hooksSource)) { + const geminiEventName = OPEN_PLUGIN_EVENT_MAP[opEventName]; + if (!geminiEventName) { + continue; + } + + const configs: HookConfig[] = []; + + // Normalize hook definition to an array of hook configs + const rawHooks: unknown[] = Array.isArray(hookDef) + ? (hookDef as unknown[]) + : [hookDef]; + + for (const rawHook of rawHooks) { + if ( + rawHook !== null && + typeof rawHook === 'object' && + !Array.isArray(rawHook) + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const rawHookRecord = rawHook as Record; + // Hydrate strings in hook definition + const hydratedHookUnknown = recursivelyHydrateStrings( + rawHookRecord, + hydrationContext, + ); + + if ( + hydratedHookUnknown !== null && + typeof hydratedHookUnknown === 'object' && + !Array.isArray(hydratedHookUnknown) + ) { + const hh = hydratedHookUnknown; + const command = hh['command']; + if (typeof command === 'string') { + const timeout = hh['timeout']; + configs.push({ + type: HookType.Command, + name: config.name, + command, + timeout: typeof timeout === 'number' ? timeout : undefined, + source: ConfigSource.Extensions, + manifestType: 'open-plugin', + pluginRoot: pluginDir, + }); + } + } + } + } + + if (configs.length > 0) { + if (!result[geminiEventName]) { + result[geminiEventName] = []; + } + result[geminiEventName].push({ + hooks: configs, + }); + } + } + + return Object.keys(result).length > 0 + ? (result as GeminiCLIExtension['hooks']) + : undefined; +} + +async function loadHooksConfigFile( + hooksPath: string, +): Promise | undefined> { + try { + const content = await fs.promises.readFile(hooksPath, 'utf-8'); + const json = JSON.parse(content) as unknown; + if (json !== null && typeof json === 'object' && !Array.isArray(json)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return json as Record; + } + } catch (_e) { + // Ignore errors + } + return undefined; +} diff --git a/packages/core/src/hooks/hookRunner.ts b/packages/core/src/hooks/hookRunner.ts index 4f44958787..d58ddeb261 100644 --- a/packages/core/src/hooks/hookRunner.ts +++ b/packages/core/src/hooks/hookRunner.ts @@ -22,6 +22,10 @@ import { } from './types.js'; import type { Config } from '../config/config.js'; import type { LLMRequest } from './hookTranslator.js'; +import { + GEMINI_TO_OPEN_PLUGIN_EVENT_MAP, + translateOpenPluginResponse, +} from './openPluginTranslator.js'; import { debugLogger } from '../utils/debugLogger.js'; import { sanitizeEnvironment } from '../services/environmentSanitization.js'; import { @@ -349,6 +353,7 @@ export class HookRunner { ...sanitizeEnvironment(process.env, this.config.sanitizationConfig), GEMINI_PROJECT_DIR: input.cwd, CLAUDE_PROJECT_DIR: input.cwd, // For compatibility + PLUGIN_ROOT: hookConfig.pluginRoot || '', ...hookConfig.env, }; @@ -407,7 +412,14 @@ export class HookRunner { // Wrap write operations in try-catch to handle synchronous EPIPE errors // that occur when the child process exits before we finish writing try { - child.stdin.write(JSON.stringify(input)); + const hookInput: HookInput = { ...input }; + if (hookConfig.manifestType === 'open-plugin') { + hookInput.hook_event_name = + GEMINI_TO_OPEN_PLUGIN_EVENT_MAP[eventName] || eventName; + hookInput.plugin_name = hookConfig.name; + hookInput.plugin_root = hookConfig.pluginRoot; + } + child.stdin.write(JSON.stringify(hookInput)); child.stdin.end(); } catch (err) { // Ignore EPIPE errors which happen when the child process closes stdin early @@ -458,8 +470,12 @@ export class HookRunner { parsed = JSON.parse(parsed); } if (parsed && typeof parsed === 'object') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - output = parsed as HookOutput; + if (hookConfig.manifestType === 'open-plugin') { + output = translateOpenPluginResponse(parsed); + } else { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + output = parsed as HookOutput; + } } } catch { // Not JSON, convert plain text to structured output diff --git a/packages/core/src/hooks/index.ts b/packages/core/src/hooks/index.ts index b8e54fdc2f..2cf1cd6628 100644 --- a/packages/core/src/hooks/index.ts +++ b/packages/core/src/hooks/index.ts @@ -20,3 +20,6 @@ export type { HookRegistryEntry } from './hookRegistry.js'; export { ConfigSource } from './types.js'; export type { AggregatedHookResult } from './hookAggregator.js'; export type { HookEventContext } from './hookPlanner.js'; + +// Export Open Plugin support +export { OPEN_PLUGIN_EVENT_MAP } from './openPluginTranslator.js'; diff --git a/packages/core/src/hooks/openPluginTranslator.ts b/packages/core/src/hooks/openPluginTranslator.ts new file mode 100644 index 0000000000..85b94056b7 --- /dev/null +++ b/packages/core/src/hooks/openPluginTranslator.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HookEventName, type HookOutput, type HookDecision } from './types.js'; + +/** + * Maps Open Plugin standard event names to Gemini CLI hook event names. + * Based on https://open-plugins.com/plugin-builders/specification#hooks + */ +export const OPEN_PLUGIN_EVENT_MAP: Record = { + onPrompt: HookEventName.BeforeAgent, + onTool: HookEventName.BeforeTool, + onModel: HookEventName.BeforeModel, + onToolSelection: HookEventName.BeforeToolSelection, + onNotification: HookEventName.Notification, + onSessionStart: HookEventName.SessionStart, + onSessionEnd: HookEventName.SessionEnd, +}; + +/** + * Maps Gemini CLI internal event names back to Open Plugin standard event names. + */ +export const GEMINI_TO_OPEN_PLUGIN_EVENT_MAP: Record = { + [HookEventName.BeforeAgent]: 'onPrompt', + [HookEventName.BeforeTool]: 'onTool', + [HookEventName.BeforeModel]: 'onModel', + [HookEventName.BeforeToolSelection]: 'onToolSelection', + [HookEventName.Notification]: 'onNotification', + [HookEventName.SessionStart]: 'onSessionStart', + [HookEventName.SessionEnd]: 'onSessionEnd', + [HookEventName.AfterAgent]: 'onPromptResponse', // Not standardized but common + [HookEventName.AfterTool]: 'onToolResponse', // Not standardized but common + [HookEventName.AfterModel]: 'onModelResponse', // Not standardized but common + [HookEventName.PreCompress]: 'onPreCompress', +}; + +/** + * Translates an Open Plugin hook response to Gemini CLI HookOutput. + */ +export function translateOpenPluginResponse( + response: Record, +): HookOutput { + if (!response || typeof response !== 'object') { + return { decision: 'allow' }; + } + + const output: HookOutput = {}; + + // Map 'allow' boolean to 'decision' enum + if (response['allow'] === false) { + output.decision = 'block' as HookDecision; + } else if (response['allow'] === true) { + output.decision = 'allow' as HookDecision; + } + + // Map 'reason' to 'reason' + const reason = response['reason']; + if (typeof reason === 'string') { + output.reason = reason; + } + + // Pass through other fields if present (e.g. tool_input, llm_request) + output.hookSpecificOutput = response; + + return output; +} diff --git a/packages/core/src/hooks/types.ts b/packages/core/src/hooks/types.ts index c1a35384ae..b2c0093bcb 100644 --- a/packages/core/src/hooks/types.ts +++ b/packages/core/src/hooks/types.ts @@ -93,6 +93,8 @@ export interface CommandHookConfig { timeout?: number; source?: ConfigSource; env?: Record; + manifestType?: 'gemini' | 'open-plugin'; + pluginRoot?: string; } export type HookConfig = CommandHookConfig | RuntimeHookConfig; @@ -135,6 +137,8 @@ export interface HookInput { cwd: string; hook_event_name: string; timestamp: string; + plugin_name?: string; + plugin_root?: string; } /**