Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/config/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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({
Expand Down
174 changes: 151 additions & 23 deletions packages/cli/src/config/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -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);

Expand All @@ -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,
};
}

Expand All @@ -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) {
Expand All @@ -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>;
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -253,6 +252,8 @@ export async function createOpenPlugin(
hydrationContext,
);

const hooks = await resolvePluginHooks(pluginDir, config, hydrationContext);

return {
name: config.name,
version: config.version,
Expand All @@ -272,6 +273,7 @@ export async function createOpenPlugin(
resolvedSettings: undefined,
skills,
agents,
hooks,
themes: undefined,
};
}
Expand Down Expand Up @@ -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 changes: 19 additions & 3 deletions packages/core/src/hooks/hookRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
69 changes: 69 additions & 0 deletions packages/core/src/hooks/openPluginTranslator.ts
Original file line number Diff line number Diff line change
@@ -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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the open plugin spec changed? Looking at the events in https://open-plugins.com/agent-builders/components/hooks#core-events there is no event name like onTool, onModel etc.

[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;
}
Loading
Loading