mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-20 00:32:31 -07:00
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:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user