mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-06-21 08:47:13 -07:00
2b8557574c
- 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
193 lines
5.3 KiB
TypeScript
193 lines
5.3 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
type MCPServerConfig,
|
|
type ExtensionInstallMetadata,
|
|
type CustomTheme,
|
|
type PolicyRule,
|
|
type SafetyCheckerRule,
|
|
type GeminiCLIExtension,
|
|
type ResolvedExtensionSetting,
|
|
} from '@google/gemini-cli-core';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { z } from 'zod';
|
|
import type { ExtensionSetting } from './extensions/extensionSettings.js';
|
|
import {
|
|
INSTALL_METADATA_FILENAME,
|
|
recursivelyHydrateStrings,
|
|
type JsonObject,
|
|
} from './extensions/variables.js';
|
|
|
|
/**
|
|
* Extension definition as written to disk in gemini-extension.json files.
|
|
* This should *not* be referenced outside of the logic for reading files.
|
|
* If information is required for manipulating extensions (load, unload, update)
|
|
* outside of the loading process that data needs to be stored on the
|
|
* GeminiCLIExtension class defined in Core.
|
|
*/
|
|
export interface ExtensionConfig {
|
|
name: string;
|
|
version: string;
|
|
manifestType?: 'gemini' | 'open-plugin';
|
|
description?: string;
|
|
author?: string | { name: string; email?: string; url?: string };
|
|
license?: string;
|
|
mcpServers?: Record<string, MCPServerConfig>;
|
|
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.
|
|
*/
|
|
themes?: CustomTheme[];
|
|
/**
|
|
* Planning features configuration contributed by this extension.
|
|
*/
|
|
plan?: {
|
|
/**
|
|
* The directory where planning artifacts are stored.
|
|
*/
|
|
directory?: string;
|
|
};
|
|
/**
|
|
* Used to migrate an extension to a new repository source.
|
|
*/
|
|
migratedTo?: string;
|
|
}
|
|
|
|
export const geminiExtensionSchema = z.object({
|
|
name: z.string().min(1),
|
|
version: z.string().min(1),
|
|
description: z.string().optional(),
|
|
author: z
|
|
.union([
|
|
z.string(),
|
|
z.object({
|
|
name: z.string(),
|
|
email: z.string().optional(),
|
|
url: z.string().optional(),
|
|
}),
|
|
])
|
|
.optional(),
|
|
license: z.string().optional(),
|
|
mcpServers: z.record(z.any()).optional(),
|
|
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({
|
|
directory: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
migratedTo: z.string().optional(),
|
|
});
|
|
|
|
export interface ExtensionUpdateInfo {
|
|
name: string;
|
|
originalVersion: string;
|
|
updatedVersion: string;
|
|
}
|
|
|
|
export function loadInstallMetadata(
|
|
extensionDir: string,
|
|
): ExtensionInstallMetadata | undefined {
|
|
const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME);
|
|
try {
|
|
const configContent = fs.readFileSync(metadataFilePath, 'utf-8');
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
|
|
return metadata;
|
|
} catch (_e) {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads a Gemini CLI extension manifest.
|
|
*/
|
|
export async function loadGeminiConfig(
|
|
manifestPath: string,
|
|
extensionDir: string,
|
|
workspaceDir: string,
|
|
): Promise<ExtensionConfig> {
|
|
const content = await fs.promises.readFile(manifestPath, 'utf-8');
|
|
const json = JSON.parse(content) as unknown;
|
|
const result = geminiExtensionSchema.safeParse(json);
|
|
if (!result.success) {
|
|
throw new Error(`Invalid gemini-extension.json: ${result.error.message}`);
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const rawConfig = result.data as unknown as ExtensionConfig;
|
|
|
|
// Hydrate strings with basic context
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
|
const config = 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 ExtensionConfig;
|
|
|
|
config.manifestType = 'gemini';
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Factory for creating a GeminiCLIExtension from a Gemini config.
|
|
*/
|
|
export function createGeminiExtension(
|
|
config: ExtensionConfig,
|
|
extensionDir: string,
|
|
isActive: boolean,
|
|
id: string,
|
|
contextFiles: string[],
|
|
resolvedSettings: ResolvedExtensionSetting[],
|
|
installMetadata?: ExtensionInstallMetadata,
|
|
hooks?: GeminiCLIExtension['hooks'],
|
|
skills?: GeminiCLIExtension['skills'],
|
|
agents?: GeminiCLIExtension['agents'],
|
|
rules?: PolicyRule[],
|
|
checkers?: SafetyCheckerRule[],
|
|
): GeminiCLIExtension {
|
|
return {
|
|
name: config.name,
|
|
version: config.version,
|
|
path: extensionDir,
|
|
isActive,
|
|
id,
|
|
installMetadata,
|
|
manifestType: 'gemini',
|
|
description: config.description,
|
|
author: config.author,
|
|
license: config.license,
|
|
contextFiles,
|
|
mcpServers: config.mcpServers,
|
|
excludeTools: config.excludeTools,
|
|
settings: config.settings,
|
|
resolvedSettings,
|
|
hooks,
|
|
skills,
|
|
agents,
|
|
themes: config.themes,
|
|
rules,
|
|
checkers,
|
|
plan: config.plan,
|
|
migratedTo: config.migratedTo,
|
|
};
|
|
}
|