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
This commit is contained in:
Taylor Mullen
2026-03-24 12:24:54 -07:00
parent 1a3741d2aa
commit 2b8557574c
6 changed files with 248 additions and 26 deletions
+2
View File
@@ -41,6 +41,7 @@ export interface ExtensionConfig {
contextFileName?: string | string[];
excludeTools?: string[];
settings?: ExtensionSetting[];
hooks?: Record<string, unknown>;
/**
* 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({
+151 -23
View File
@@ -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<string, unknown> | undefined,
};
}
@@ -153,18 +153,18 @@ async function resolveMcpServers(
let mcpServers: Record<string, MCPServerConfig> | 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<string, MCPServerConfig>
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
mcpServers = hydratedConfig.mcpServers as Record<string, MCPServerConfig>;
mcpServers = rawMcpServers as Record<string, MCPServerConfig>;
}
}
@@ -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<string, MCPServerConfig>;
}
} 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<string, string>,
): Promise<GeminiCLIExtension['hooks']> {
let hooksSource: Record<string, unknown> | 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<string, Array<{ hooks: HookConfig[] }>> = {};
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<string, unknown>;
// 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<Record<string, unknown> | 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<string, unknown>;
}
} catch (_e) {
// Ignore errors
}
return undefined;
}
+19 -3
View File
@@ -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
+3
View File
@@ -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';
@@ -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<string, HookEventName> = {
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, string> = {
[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<string, unknown>,
): 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;
}
+4
View File
@@ -93,6 +93,8 @@ export interface CommandHookConfig {
timeout?: number;
source?: ConfigSource;
env?: Record<string, string>;
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;
}
/**