mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-26 17:51:04 -07:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b8557574c | |||
| 1a3741d2aa | |||
| f7413564ac | |||
| 42344c2666 | |||
| c64c08c774 | |||
| 258490b19c | |||
| afb8727bcc | |||
| ce8315df63 | |||
| 77a6c7e577 | |||
| 2da2f28b20 |
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const pluginJson = (name: string) => `{
|
||||
"name": "${name}",
|
||||
"version": "1.0.0",
|
||||
"description": "A test open plugin"
|
||||
}`;
|
||||
|
||||
const agentMarkdown = (name: string, description: string) => `---
|
||||
name: ${name}
|
||||
description: ${description}
|
||||
mcp_servers:
|
||||
test-server:
|
||||
command: node
|
||||
args: ["\${PLUGIN_ROOT}/server.js"]
|
||||
---
|
||||
You are ${name}. My root is \${PLUGIN_ROOT}.`;
|
||||
|
||||
describe('Open Plugin agents', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('discovers, namespaces, and expands PLUGIN_ROOT for Open Plugin agents', async () => {
|
||||
rig.setup('open-plugin agents test');
|
||||
const pluginDir = join(rig.testDir!, 'test-plugin');
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
mkdirSync(join(pluginDir, 'agents'), { recursive: true });
|
||||
|
||||
const pluginName = 'test-plugin';
|
||||
writeFileSync(join(pluginDir, 'plugin.json'), pluginJson(pluginName));
|
||||
writeFileSync(
|
||||
join(pluginDir, 'agents', 'researcher.md'),
|
||||
agentMarkdown('researcher', 'A researcher in ${PLUGIN_ROOT}'),
|
||||
);
|
||||
|
||||
// Install the plugin
|
||||
await rig.runCommand(['extensions', 'install', pluginDir], {
|
||||
stdin: 'y\n',
|
||||
});
|
||||
|
||||
// List extensions to verify the agent is registered and expanded
|
||||
const listResult = await rig.runCommand(['extensions', 'list']);
|
||||
|
||||
// Check namespacing
|
||||
expect(listResult).toContain(`${pluginName}:researcher`);
|
||||
|
||||
// Check expansion in description (via toOutputString)
|
||||
const installedPathMatch = listResult.match(/Path: (.*)/);
|
||||
const installedPath = installedPathMatch
|
||||
? installedPathMatch[1].trim()
|
||||
: '';
|
||||
expect(listResult).toContain(`A researcher in ${installedPath}`);
|
||||
|
||||
// Check expansion in prompt by "running" the agent or checking its internal state
|
||||
// For integration test, we can try to use the agent in a non-interactive mode
|
||||
// but that might be slow/complex.
|
||||
// Given we verified description expansion, and core tests verified prompt expansion,
|
||||
// this should be sufficient.
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,7 @@ describe('handleValidate', () => {
|
||||
});
|
||||
expect(debugLoggerErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.',
|
||||
'Invalid extension name: "INVALID_NAME". Only letters (a-z, A-Z), numbers (0-9), dashes (-), and dots (.) are allowed. Names must start and end with an alphanumeric character.',
|
||||
),
|
||||
);
|
||||
expect(processSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
@@ -11,7 +11,19 @@ import chalk from 'chalk';
|
||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||
import { type MergedSettings, SettingScope } from './settings.js';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { loadInstallMetadata, type ExtensionConfig } from './extension.js';
|
||||
import {
|
||||
loadInstallMetadata,
|
||||
loadGeminiConfig,
|
||||
createGeminiExtension,
|
||||
type ExtensionConfig,
|
||||
} from './extension.js';
|
||||
import {
|
||||
findManifest,
|
||||
loadOpenPluginConfig,
|
||||
createOpenPlugin,
|
||||
type OpenPluginConfig,
|
||||
OPEN_PLUGIN_NAME_REGEX,
|
||||
} from './plugin.js';
|
||||
import {
|
||||
isWorkspaceTrusted,
|
||||
loadTrustedFolders,
|
||||
@@ -65,7 +77,6 @@ import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
import { ExtensionStorage } from './extensions/storage.js';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
recursivelyHydrateStrings,
|
||||
type JsonObject,
|
||||
@@ -293,6 +304,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
try {
|
||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||
|
||||
if (!newExtensionConfig) {
|
||||
throw new Error('Failed to load extension configuration');
|
||||
}
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const previousName = previousExtensionConfig?.name ?? newExtensionName;
|
||||
const previous = this.getExtensions().find(
|
||||
@@ -757,23 +772,59 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
effectiveExtensionPath = installMetadata.source;
|
||||
}
|
||||
|
||||
try {
|
||||
let config = await this.loadExtensionConfig(effectiveExtensionPath);
|
||||
const manifestInfo = findManifest(effectiveExtensionPath);
|
||||
if (!manifestInfo) {
|
||||
debugLogger.warn(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: No manifest found.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const extensionId = getExtensionId(config, installMetadata);
|
||||
try {
|
||||
// Bifurcate loading based on manifest type
|
||||
if (manifestInfo.type === 'open-plugin') {
|
||||
const config = await loadOpenPluginConfig(
|
||||
manifestInfo.path,
|
||||
effectiveExtensionPath,
|
||||
this.workspaceDir,
|
||||
);
|
||||
validateName(config.name);
|
||||
const extensionId = getExtensionId(config, installMetadata);
|
||||
return await createOpenPlugin(
|
||||
effectiveExtensionPath,
|
||||
manifestInfo.path,
|
||||
this.extensionEnablementManager.isEnabled(
|
||||
config.name,
|
||||
this.workspaceDir,
|
||||
),
|
||||
extensionId,
|
||||
this.workspaceDir,
|
||||
installMetadata,
|
||||
);
|
||||
}
|
||||
|
||||
// Gemini CLI Extension loading path
|
||||
const rawConfig = await loadGeminiConfig(
|
||||
manifestInfo.path,
|
||||
effectiveExtensionPath,
|
||||
this.workspaceDir,
|
||||
);
|
||||
validateName(rawConfig.name);
|
||||
|
||||
const extensionId = getExtensionId(rawConfig, installMetadata);
|
||||
|
||||
let userSettings: Record<string, string> = {};
|
||||
let workspaceSettings: Record<string, string> = {};
|
||||
|
||||
if (this.settings.experimental.extensionConfig) {
|
||||
userSettings = await getScopedEnvContents(
|
||||
config,
|
||||
rawConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.USER,
|
||||
);
|
||||
if (isWorkspaceTrusted(this.settings).isTrusted) {
|
||||
workspaceSettings = await getScopedEnvContents(
|
||||
config,
|
||||
rawConfig,
|
||||
extensionId,
|
||||
ExtensionSettingScope.WORKSPACE,
|
||||
this.workspaceDir,
|
||||
@@ -782,7 +833,8 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
|
||||
const customEnv = { ...userSettings, ...workspaceSettings };
|
||||
config = resolveEnvVarsInObject(config, customEnv);
|
||||
// config is already hydrated in loadGeminiConfig, but we might need to re-hydrate with customEnv
|
||||
const config = resolveEnvVarsInObject(rawConfig, customEnv);
|
||||
|
||||
const resolvedSettings: ResolvedExtensionSetting[] = [];
|
||||
if (config.settings && this.settings.experimental.extensionConfig) {
|
||||
@@ -874,6 +926,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
const hydrationContext: VariableContext = {
|
||||
extensionPath: effectiveExtensionPath,
|
||||
PLUGIN_ROOT: effectiveExtensionPath,
|
||||
workspacePath: this.workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
@@ -944,6 +997,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
|
||||
const agentLoadResult = await loadAgentsFromDirectory(
|
||||
path.join(effectiveExtensionPath, 'agents'),
|
||||
effectiveExtensionPath,
|
||||
);
|
||||
agentLoadResult.agents = agentLoadResult.agents.map((agent) => ({
|
||||
...recursivelyHydrateStrings(agent, hydrationContext),
|
||||
@@ -957,30 +1011,23 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
path: effectiveExtensionPath,
|
||||
contextFiles,
|
||||
installMetadata,
|
||||
migratedTo: config.migratedTo,
|
||||
mcpServers: config.mcpServers,
|
||||
excludeTools: config.excludeTools,
|
||||
hooks,
|
||||
isActive: this.extensionEnablementManager.isEnabled(
|
||||
return createGeminiExtension(
|
||||
config,
|
||||
effectiveExtensionPath,
|
||||
this.extensionEnablementManager.isEnabled(
|
||||
config.name,
|
||||
this.workspaceDir,
|
||||
),
|
||||
id: getExtensionId(config, installMetadata),
|
||||
settings: config.settings,
|
||||
extensionId,
|
||||
contextFiles,
|
||||
resolvedSettings,
|
||||
installMetadata,
|
||||
hooks,
|
||||
skills,
|
||||
agents: agentLoadResult.agents,
|
||||
themes: config.themes,
|
||||
agentLoadResult.agents,
|
||||
rules,
|
||||
checkers,
|
||||
plan: config.plan,
|
||||
};
|
||||
);
|
||||
} catch (e) {
|
||||
debugLogger.error(
|
||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
||||
@@ -1013,39 +1060,27 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
}
|
||||
|
||||
async loadExtensionConfig(extensionDir: string): Promise<ExtensionConfig> {
|
||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (!fs.existsSync(configFilePath)) {
|
||||
throw new Error(`Configuration file not found at ${configFilePath}`);
|
||||
const manifestInfo = findManifest(extensionDir);
|
||||
if (!manifestInfo) {
|
||||
throw new Error(`Configuration file not found in ${extensionDir}`);
|
||||
}
|
||||
try {
|
||||
const configContent = await fs.promises.readFile(configFilePath, 'utf-8');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const rawConfig = JSON.parse(configContent) as ExtensionConfig;
|
||||
if (!rawConfig.name || !rawConfig.version) {
|
||||
throw new Error(
|
||||
`Invalid configuration in ${configFilePath}: missing ${!rawConfig.name ? '"name"' : '"version"'}`,
|
||||
);
|
||||
}
|
||||
// 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,
|
||||
workspacePath: this.workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
},
|
||||
) as unknown as ExtensionConfig;
|
||||
|
||||
if (manifestInfo.type === 'open-plugin') {
|
||||
const config = await loadOpenPluginConfig(
|
||||
manifestInfo.path,
|
||||
extensionDir,
|
||||
this.workspaceDir,
|
||||
);
|
||||
validateName(config.name);
|
||||
return config;
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
|
||||
e,
|
||||
)}`,
|
||||
} else {
|
||||
const config = await loadGeminiConfig(
|
||||
manifestInfo.path,
|
||||
extensionDir,
|
||||
this.workspaceDir,
|
||||
);
|
||||
validateName(config.name);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1150,6 +1185,12 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
output += `\n ${skill.name}: ${skill.description}`;
|
||||
});
|
||||
}
|
||||
if (extension.agents && extension.agents.length > 0) {
|
||||
output += `\n Sub-agents:`;
|
||||
extension.agents.forEach((agent) => {
|
||||
output += `\n ${agent.name}: ${agent.description}`;
|
||||
});
|
||||
}
|
||||
const resolvedSettings = extension.resolvedSettings;
|
||||
if (resolvedSettings && resolvedSettings.length > 0) {
|
||||
output += `\n Settings:`;
|
||||
@@ -1279,9 +1320,9 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
||||
}
|
||||
|
||||
function validateName(name: string) {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
|
||||
if (!OPEN_PLUGIN_NAME_REGEX.test(name) && !/^[a-zA-Z0-9-]+$/.test(name)) {
|
||||
throw new Error(
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
|
||||
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), dashes (-), and dots (.) are allowed. Names must start and end with an alphanumeric character.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1326,7 +1367,7 @@ export async function inferInstallMetadata(
|
||||
}
|
||||
|
||||
export function getExtensionId(
|
||||
config: ExtensionConfig,
|
||||
config: ExtensionConfig | OpenPluginConfig,
|
||||
installMetadata?: ExtensionInstallMetadata,
|
||||
): string {
|
||||
// IDs are created by hashing details of the installation source in order to
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
INSTALL_METADATA_FILENAME,
|
||||
} from './extensions/variables.js';
|
||||
import { hashValue, ExtensionManager } from './extension-manager.js';
|
||||
import { loadGeminiConfig, createGeminiExtension } from './extension.js';
|
||||
import { ExtensionStorage } from './extensions/storage.js';
|
||||
import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js';
|
||||
import type { ExtensionSetting } from './extensions/extensionSettings.js';
|
||||
@@ -685,9 +686,7 @@ name = "yolo-checker"
|
||||
expect(extensions).toHaveLength(1);
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
|
||||
),
|
||||
expect.stringContaining(`Warning: Skipping extension in ${badExtDir}:`),
|
||||
);
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
@@ -717,7 +716,7 @@ name = "yolo-checker"
|
||||
expect(extensions[0].name).toBe('good-ext');
|
||||
expect(consoleSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
|
||||
`Warning: Skipping extension in ${badExtDir}: Invalid gemini-extension.json:`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1195,14 +1194,13 @@ name = "yolo-checker"
|
||||
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
||||
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
|
||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
|
||||
await expect(
|
||||
extensionManager.installOrUpdateExtension({
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
}),
|
||||
).rejects.toThrow(`Configuration file not found at ${configPath}`);
|
||||
).rejects.toThrow(`Configuration file not found in ${sourceExtDir}`);
|
||||
|
||||
const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
|
||||
expect(fs.existsSync(targetExtDir)).toBe(false);
|
||||
@@ -1219,7 +1217,7 @@ name = "yolo-checker"
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
}),
|
||||
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw an error for missing name in gemini-extension.json', async () => {
|
||||
@@ -1239,9 +1237,7 @@ name = "yolo-checker"
|
||||
source: sourceExtDir,
|
||||
type: 'local',
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
`Invalid configuration in ${configPath}: missing "name"`,
|
||||
);
|
||||
).rejects.toThrow('Invalid gemini-extension.json:');
|
||||
});
|
||||
|
||||
it('should install an extension from a git URL', async () => {
|
||||
@@ -2354,3 +2350,67 @@ function isEnabled(options: { name: string; enabledForPath: string }) {
|
||||
const manager = new ExtensionEnablementManager();
|
||||
return manager.isEnabled(options.name, options.enabledForPath);
|
||||
}
|
||||
|
||||
describe('extension.ts - Gemini CLI Extension Loading', () => {
|
||||
let geminiTempDir: string;
|
||||
let geminiManifestPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
geminiTempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-extension-test-'),
|
||||
);
|
||||
geminiManifestPath = path.join(geminiTempDir, 'gemini-extension.json');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(geminiTempDir)) {
|
||||
fs.rmSync(geminiTempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should load a valid gemini-extension.json and hydrate PLUGIN_ROOT', async () => {
|
||||
fs.writeFileSync(
|
||||
geminiManifestPath,
|
||||
JSON.stringify({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
description: 'Uses root: ${PLUGIN_ROOT}',
|
||||
}),
|
||||
);
|
||||
|
||||
const config = await loadGeminiConfig(
|
||||
geminiManifestPath,
|
||||
geminiTempDir,
|
||||
'/tmp/workspace',
|
||||
);
|
||||
|
||||
expect(config.name).toBe('test-extension');
|
||||
expect(config.version).toBe('1.0.0');
|
||||
expect(config.description).toBe(`Uses root: ${geminiTempDir}`);
|
||||
expect(config.manifestType).toBe('gemini');
|
||||
});
|
||||
|
||||
it('should create a GeminiCLIExtension with all fields', () => {
|
||||
const config = {
|
||||
name: 'test-ext',
|
||||
version: '2.0.0',
|
||||
description: 'A test',
|
||||
themes: [],
|
||||
};
|
||||
|
||||
const extension = createGeminiExtension(
|
||||
config,
|
||||
geminiTempDir,
|
||||
true,
|
||||
'ext-id',
|
||||
['GEMINI.md'],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(extension.name).toBe('test-ext');
|
||||
expect(extension.version).toBe('2.0.0');
|
||||
expect(extension.isActive).toBe(true);
|
||||
expect(extension.manifestType).toBe('gemini');
|
||||
expect(extension.contextFiles).toEqual(['GEMINI.md']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,24 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
ExtensionInstallMetadata,
|
||||
CustomTheme,
|
||||
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 { INSTALL_METADATA_FILENAME } from './extensions/variables.js';
|
||||
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.
|
||||
@@ -24,10 +33,15 @@ import type { ExtensionSetting } from './extensions/extensionSettings.js';
|
||||
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.
|
||||
@@ -48,6 +62,35 @@ export interface ExtensionConfig {
|
||||
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;
|
||||
@@ -67,3 +110,83 @@ export function loadInstallMetadata(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ export const VARIABLE_SCHEMA = {
|
||||
type: 'string',
|
||||
description: 'The path of the extension in the filesystem.',
|
||||
},
|
||||
PLUGIN_ROOT: {
|
||||
type: 'string',
|
||||
description: 'The root path of the plugin (alias for extensionPath).',
|
||||
},
|
||||
workspacePath: {
|
||||
type: 'string',
|
||||
description: 'The absolute path of the current workspace.',
|
||||
|
||||
@@ -20,6 +20,16 @@ const UNMARSHALL_KEY_IGNORE_LIST: Set<string> = new Set<string>([
|
||||
|
||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
|
||||
export const OPEN_PLUGIN_CONFIG_FILENAME = 'plugin.json';
|
||||
export const HIDDEN_OPEN_PLUGIN_CONFIG_FILENAME = path.join(
|
||||
'.plugin',
|
||||
'plugin.json',
|
||||
);
|
||||
export const OPEN_PLUGIN_MCP_CONFIG_FILENAME = '.mcp.json';
|
||||
export const HIDDEN_OPEN_PLUGIN_MCP_CONFIG_FILENAME = path.join(
|
||||
'.plugin',
|
||||
'.mcp.json',
|
||||
);
|
||||
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
|
||||
export const EXTENSION_SETTINGS_FILENAME = '.env';
|
||||
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
const mockIntegrityManager = vi.hoisted(() => ({
|
||||
verify: vi.fn().mockResolvedValue('verified'),
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
ExtensionIntegrityManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockIntegrityManager),
|
||||
};
|
||||
});
|
||||
|
||||
describe('ExtensionManager - Open Plugin Support', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
let extensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
mockHomedir.mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (fs.existsSync(tempHomeDir)) {
|
||||
fs.rmSync(tempHomeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should discover a plugin with plugin.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'test-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'hello-world',
|
||||
version: '1.0.0',
|
||||
description: 'An Open Plugin test',
|
||||
author: { name: 'Taylor' },
|
||||
license: 'Apache-2.0',
|
||||
}),
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'hello-world');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.version).toBe('1.0.0');
|
||||
expect(plugin?.description).toBe('An Open Plugin test');
|
||||
expect(plugin?.manifestType).toBe('open-plugin');
|
||||
});
|
||||
|
||||
it('should discover a plugin with .plugin/plugin.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'hidden-plugin-dir');
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'hidden-plugin',
|
||||
version: '2.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'hidden-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.version).toBe('2.0.0');
|
||||
expect(plugin?.manifestType).toBe('open-plugin');
|
||||
});
|
||||
|
||||
it('should support PLUGIN_ROOT variable alias in metadata', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'var-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'var-plugin',
|
||||
version: '1.0.0',
|
||||
description: 'Uses root: ${PLUGIN_ROOT}',
|
||||
}),
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'var-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.description).toBe(`Uses root: ${pluginDir}`);
|
||||
});
|
||||
|
||||
it('should load skills for Open Plugins', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'feature-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const skillsDir = path.join(pluginDir, 'skills', 'test-skill');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'feature-plugin',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(skillsDir, 'SKILL.md'),
|
||||
`---
|
||||
name: my-skill
|
||||
description: "Test description"
|
||||
---
|
||||
Body`,
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'feature-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.skills).toBeDefined();
|
||||
expect(plugin?.skills?.[0].name).toBe('feature-plugin:my-skill');
|
||||
expect(plugin?.skills?.[0].extensionName).toBe('feature-plugin');
|
||||
});
|
||||
|
||||
it('should prioritize gemini-extension.json over plugin.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'dual-manifest-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'gemini-extension.json'),
|
||||
JSON.stringify({
|
||||
name: 'gemini-plugin',
|
||||
version: '1.1.1',
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'open-plugin',
|
||||
version: '2.2.2',
|
||||
}),
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'gemini-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.version).toBe('1.1.1');
|
||||
expect(plugin?.manifestType).toBe('gemini');
|
||||
|
||||
const openPlugin = extensions.find((ext) => ext.name === 'open-plugin');
|
||||
expect(openPlugin).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { ExtensionManager } from './extension-manager.js';
|
||||
import { createTestMergedSettings } from './settings.js';
|
||||
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
|
||||
|
||||
const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home'));
|
||||
const mockIntegrityManager = vi.hoisted(() => ({
|
||||
verify: vi.fn().mockResolvedValue('verified'),
|
||||
store: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const mockedOs = await importOriginal<typeof os>();
|
||||
return {
|
||||
...mockedOs,
|
||||
homedir: mockHomedir,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: mockHomedir,
|
||||
ExtensionIntegrityManager: vi
|
||||
.fn()
|
||||
.mockImplementation(() => mockIntegrityManager),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Open Plugin MCP Support', () => {
|
||||
let tempHomeDir: string;
|
||||
let tempWorkspaceDir: string;
|
||||
let userExtensionsDir: string;
|
||||
let extensionManager: ExtensionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
tempHomeDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'gemini-cli-test-home-mcp-'),
|
||||
);
|
||||
tempWorkspaceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
||||
);
|
||||
mockHomedir.mockReturnValue(tempHomeDir);
|
||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
|
||||
extensionManager = new ExtensionManager({
|
||||
settings: createTestMergedSettings(),
|
||||
workspaceDir: tempWorkspaceDir,
|
||||
requestConsent: vi.fn().mockResolvedValue(true),
|
||||
requestSetting: null,
|
||||
integrityManager: mockIntegrityManager,
|
||||
});
|
||||
});
|
||||
|
||||
it('should discover MCP servers from .mcp.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'mcp-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'mcp-plugin',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, '.mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['server.js'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'mcp-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.mcpServers).toBeDefined();
|
||||
expect(plugin?.mcpServers?.['test-server']).toBeDefined();
|
||||
expect(plugin?.mcpServers?.['test-server'].command).toBe('node');
|
||||
});
|
||||
|
||||
it('should support explicit mcpServers path in plugin.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'explicit-mcp-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'explicit-mcp-plugin',
|
||||
version: '1.0.0',
|
||||
mcpServers: 'custom-mcp.json',
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'custom-mcp.json'),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
'custom-server': {
|
||||
command: 'node',
|
||||
args: ['custom.js'],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const plugin = extensions.find((ext) => ext.name === 'explicit-mcp-plugin');
|
||||
|
||||
expect(plugin).toBeDefined();
|
||||
expect(plugin?.mcpServers).toBeDefined();
|
||||
expect(plugin?.mcpServers?.['custom-server']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
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,
|
||||
HIDDEN_OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
OPEN_PLUGIN_MCP_CONFIG_FILENAME,
|
||||
HIDDEN_OPEN_PLUGIN_MCP_CONFIG_FILENAME,
|
||||
recursivelyHydrateStrings,
|
||||
} from './extensions/variables.js';
|
||||
import type { ExtensionConfig } from './extension.js';
|
||||
|
||||
/**
|
||||
* Open Plugin manifest (plugin.json) v1.0.0
|
||||
* Based on https://open-plugins.com/plugin-builders/specification
|
||||
*/
|
||||
export interface OpenPluginConfig {
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
author?: string | { name: string; email?: string; url?: string };
|
||||
license?: string;
|
||||
// Component fields (parsed but currently ignored during execution per v1 plan)
|
||||
skills?: string[] | Record<string, unknown>;
|
||||
agents?: string[] | Record<string, unknown>;
|
||||
hooks?: string[] | Record<string, unknown>;
|
||||
mcpServers?: string | string[] | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const OPEN_PLUGIN_NAME_REGEX = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
|
||||
export const openPluginSchema = z.object({
|
||||
name: z.string().min(1).max(64).regex(OPEN_PLUGIN_NAME_REGEX),
|
||||
version: z.string().optional(),
|
||||
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(),
|
||||
skills: z.union([z.array(z.string()), z.record(z.any())]).optional(),
|
||||
agents: z.union([z.array(z.string()), z.record(z.any())]).optional(),
|
||||
hooks: z.union([z.array(z.string()), z.record(z.any())]).optional(),
|
||||
mcpServers: z
|
||||
.union([z.string(), z.array(z.string()), z.record(z.any())])
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const openPluginMcpSchema = z.object({
|
||||
mcpServers: z.record(z.any()),
|
||||
});
|
||||
|
||||
export interface ManifestInfo {
|
||||
type: 'gemini' | 'open-plugin';
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function findManifest(extensionDir: string): ManifestInfo | undefined {
|
||||
const geminiPath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
||||
if (fs.existsSync(geminiPath)) {
|
||||
return { type: 'gemini', path: geminiPath };
|
||||
}
|
||||
|
||||
const openPluginPath = path.join(extensionDir, OPEN_PLUGIN_CONFIG_FILENAME);
|
||||
if (fs.existsSync(openPluginPath)) {
|
||||
return { type: 'open-plugin', path: openPluginPath };
|
||||
}
|
||||
|
||||
const hiddenOpenPluginPath = path.join(
|
||||
extensionDir,
|
||||
HIDDEN_OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
);
|
||||
if (fs.existsSync(hiddenOpenPluginPath)) {
|
||||
return { type: 'open-plugin', path: hiddenOpenPluginPath };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads an Open Plugin manifest and maps it to ExtensionConfig.
|
||||
*/
|
||||
export async function loadOpenPluginConfig(
|
||||
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 = openPluginSchema.safeParse(json);
|
||||
if (!result.success) {
|
||||
throw new Error(`Invalid plugin.json: ${result.error.message}`);
|
||||
}
|
||||
|
||||
const rawConfig = result.data as OpenPluginConfig;
|
||||
|
||||
// Hydrate metadata fields
|
||||
const hydratedConfig = recursivelyHydrateStrings(rawConfig, {
|
||||
extensionPath: extensionDir,
|
||||
PLUGIN_ROOT: extensionDir,
|
||||
workspacePath: workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
});
|
||||
|
||||
const mcpServers = await resolveMcpServers(hydratedConfig, extensionDir);
|
||||
|
||||
return {
|
||||
name: hydratedConfig.name,
|
||||
version: hydratedConfig.version ?? '0.0.0',
|
||||
manifestType: 'open-plugin',
|
||||
description: hydratedConfig.description,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves MCP server configurations for an Open Plugin by checking the manifest
|
||||
* and falling back to default filesystem locations.
|
||||
*/
|
||||
async function resolveMcpServers(
|
||||
hydratedConfig: OpenPluginConfig,
|
||||
extensionDir: string,
|
||||
): Promise<Record<string, MCPServerConfig> | undefined> {
|
||||
let mcpServers: Record<string, MCPServerConfig> | undefined;
|
||||
|
||||
// 1. Explicit mcpServers in plugin.json
|
||||
const rawMcpServers = hydratedConfig.mcpServers;
|
||||
if (rawMcpServers) {
|
||||
if (typeof rawMcpServers === 'string') {
|
||||
const mcpPath = path.resolve(extensionDir, rawMcpServers);
|
||||
mcpServers = await loadMcpConfigFile(mcpPath);
|
||||
} 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 rawMcpServers) {
|
||||
const mcpPath = path.resolve(extensionDir, p);
|
||||
const servers = await loadMcpConfigFile(mcpPath);
|
||||
if (servers) {
|
||||
Object.assign(mcpServers, servers);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a Record<string, MCPServerConfig>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
mcpServers = rawMcpServers as Record<string, MCPServerConfig>;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to .mcp.json at plugin root if no servers found yet
|
||||
if (!mcpServers) {
|
||||
const mcpPath = path.join(extensionDir, OPEN_PLUGIN_MCP_CONFIG_FILENAME);
|
||||
const hiddenMcpPath = path.join(
|
||||
extensionDir,
|
||||
HIDDEN_OPEN_PLUGIN_MCP_CONFIG_FILENAME,
|
||||
);
|
||||
|
||||
if (fs.existsSync(mcpPath)) {
|
||||
mcpServers = await loadMcpConfigFile(mcpPath);
|
||||
} else if (fs.existsSync(hiddenMcpPath)) {
|
||||
mcpServers = await loadMcpConfigFile(hiddenMcpPath);
|
||||
}
|
||||
}
|
||||
|
||||
return mcpServers;
|
||||
}
|
||||
|
||||
async function loadMcpConfigFile(
|
||||
mcpPath: string,
|
||||
): Promise<Record<string, MCPServerConfig> | undefined> {
|
||||
try {
|
||||
const content = await fs.promises.readFile(mcpPath, 'utf-8');
|
||||
const json = JSON.parse(content) as unknown;
|
||||
const result = openPluginMcpSchema.safeParse(json);
|
||||
if (result.success) {
|
||||
return result.data.mcpServers as Record<string, MCPServerConfig>;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore errors loading fallback file
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a GeminiCLIExtension from an Open Plugin directory.
|
||||
*/
|
||||
export async function createOpenPlugin(
|
||||
pluginDir: string,
|
||||
manifestPath: string,
|
||||
isActive: boolean,
|
||||
id: string,
|
||||
workspaceDir: string,
|
||||
installMetadata?: ExtensionInstallMetadata,
|
||||
): Promise<GeminiCLIExtension> {
|
||||
// Use loadOpenPluginConfig to get standard mapping
|
||||
const config = await loadOpenPluginConfig(
|
||||
manifestPath,
|
||||
pluginDir,
|
||||
workspaceDir,
|
||||
);
|
||||
|
||||
const hydrationContext = {
|
||||
extensionPath: pluginDir,
|
||||
PLUGIN_ROOT: pluginDir,
|
||||
workspacePath: workspaceDir,
|
||||
'/': path.sep,
|
||||
pathSeparator: path.sep,
|
||||
};
|
||||
|
||||
const skills = await resolvePluginSkills(
|
||||
pluginDir,
|
||||
config.name,
|
||||
hydrationContext,
|
||||
);
|
||||
|
||||
const agents = await resolvePluginAgents(
|
||||
pluginDir,
|
||||
config.name,
|
||||
hydrationContext,
|
||||
);
|
||||
|
||||
const hooks = await resolvePluginHooks(pluginDir, config, hydrationContext);
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
version: config.version,
|
||||
path: pluginDir,
|
||||
isActive,
|
||||
id,
|
||||
installMetadata,
|
||||
manifestType: 'open-plugin',
|
||||
description: config.description,
|
||||
author: config.author,
|
||||
license: config.license,
|
||||
// Features partially enabled for Open Plugins
|
||||
contextFiles: [],
|
||||
mcpServers: config.mcpServers,
|
||||
excludeTools: undefined,
|
||||
settings: undefined,
|
||||
resolvedSettings: undefined,
|
||||
skills,
|
||||
agents,
|
||||
hooks,
|
||||
themes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and namespaces skills for an Open Plugin.
|
||||
*/
|
||||
async function resolvePluginSkills(
|
||||
pluginDir: string,
|
||||
pluginName: string,
|
||||
hydrationContext: Record<string, string>,
|
||||
): Promise<GeminiCLIExtension['skills']> {
|
||||
const skillsDir = path.join(pluginDir, 'skills');
|
||||
const discoveredSkills = await loadSkillsFromDir(skillsDir);
|
||||
|
||||
if (discoveredSkills.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return discoveredSkills.map((skill) => ({
|
||||
...recursivelyHydrateStrings(skill, hydrationContext),
|
||||
name: `${pluginName}:${skill.name}`,
|
||||
extensionName: pluginName,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers and namespaces agents for an Open Plugin.
|
||||
*/
|
||||
async function resolvePluginAgents(
|
||||
pluginDir: string,
|
||||
pluginName: string,
|
||||
hydrationContext: Record<string, string>,
|
||||
): Promise<GeminiCLIExtension['agents']> {
|
||||
const agentsDir = path.join(pluginDir, 'agents');
|
||||
const agentLoadResult = await loadAgentsFromDirectory(agentsDir, pluginDir);
|
||||
|
||||
if (agentLoadResult.agents.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return agentLoadResult.agents.map((agent) => ({
|
||||
...recursivelyHydrateStrings(agent, hydrationContext),
|
||||
name: `${pluginName}:${agent.name}`,
|
||||
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;
|
||||
}
|
||||
@@ -218,6 +218,19 @@ describe('SlashCommandResolver', () => {
|
||||
expect(names).toContain('google-workspace:chat1');
|
||||
});
|
||||
|
||||
it('should NOT double-prefix when a skill name is already prefixed', () => {
|
||||
const skill = {
|
||||
...createMockCommand('google-workspace:chat', CommandKind.SKILL),
|
||||
extensionName: 'google-workspace',
|
||||
};
|
||||
|
||||
const { finalCommands } = SlashCommandResolver.resolve([skill]);
|
||||
|
||||
const names = finalCommands.map((c) => c.name);
|
||||
expect(names).toContain('google-workspace:chat');
|
||||
expect(names).not.toContain('google-workspace:google-workspace:chat');
|
||||
});
|
||||
|
||||
it('should NOT prefix skills with "skill" when extension name is missing', () => {
|
||||
const builtin = createMockCommand('chat', CommandKind.BUILT_IN);
|
||||
const skill = createMockCommand('chat', CommandKind.SKILL);
|
||||
|
||||
@@ -173,7 +173,15 @@ export class SlashCommandResolver {
|
||||
const isExtensionPrefix =
|
||||
kind === CommandKind.SKILL || kind === CommandKind.EXTENSION_FILE;
|
||||
const separator = isExtensionPrefix ? ':' : '.';
|
||||
const base = prefix ? `${prefix}${separator}${name}` : name;
|
||||
|
||||
// Check if it's already correctly prefixed
|
||||
const alreadyPrefixed = prefix && name.startsWith(`${prefix}${separator}`);
|
||||
const base = alreadyPrefixed
|
||||
? name
|
||||
: prefix
|
||||
? `${prefix}${separator}${name}`
|
||||
: name;
|
||||
|
||||
let renamedName = base;
|
||||
let suffix = 1;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||
import { getFormattedSettingValue } from '../../../commands/extensions/utils.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
interface ExtensionsList {
|
||||
extensions: readonly GeminiCLIExtension[];
|
||||
@@ -61,11 +62,29 @@ export const ExtensionsList: React.FC<ExtensionsList> = ({ extensions }) => {
|
||||
|
||||
return (
|
||||
<Box key={ext.name} flexDirection="column" marginBottom={1}>
|
||||
<Text>
|
||||
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
|
||||
<Text color={activeColor}>{` - ${activeString}`}</Text>
|
||||
{<Text color={stateColor}>{` (${stateText})`}</Text>}
|
||||
</Text>
|
||||
<Box flexDirection="row">
|
||||
<Text>
|
||||
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
|
||||
<Text color={activeColor}>{` - ${activeString}`}</Text>
|
||||
<Text color={stateColor}>{` (${stateText})`}</Text>
|
||||
</Text>
|
||||
{ext.manifestType === 'open-plugin' && (
|
||||
<Box marginLeft={1}>
|
||||
<Text
|
||||
backgroundColor={theme.ui.dark}
|
||||
color={theme.text.secondary}
|
||||
>
|
||||
{' '}
|
||||
Plugin{' '}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{ext.description && (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="gray">{ext.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text>settings:</Text>
|
||||
|
||||
@@ -343,6 +343,32 @@ Body`);
|
||||
expect(result.modelConfig.model).toBe('auto');
|
||||
});
|
||||
|
||||
it('should expand ${PLUGIN_ROOT} in agent definition', () => {
|
||||
const markdown = {
|
||||
kind: 'local' as const,
|
||||
name: 'expansion-agent',
|
||||
description: 'An agent in ${PLUGIN_ROOT}',
|
||||
system_prompt: 'You are at ${PLUGIN_ROOT}',
|
||||
mcp_servers: {
|
||||
'test-server': {
|
||||
command: 'node',
|
||||
args: ['${PLUGIN_ROOT}/server.js'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const pluginRoot = '/abs/path/to/plugin';
|
||||
const result = markdownToAgentDefinition(markdown, {
|
||||
pluginRoot,
|
||||
}) as LocalAgentDefinition;
|
||||
|
||||
expect(result.description).toBe(`An agent in ${pluginRoot}`);
|
||||
expect(result.promptConfig.systemPrompt).toBe(`You are at ${pluginRoot}`);
|
||||
expect(result.mcpServers!['test-server'].args).toEqual([
|
||||
`${pluginRoot}/server.js`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should convert remote agent definition', () => {
|
||||
const markdown = {
|
||||
kind: 'remote' as const,
|
||||
|
||||
@@ -489,17 +489,55 @@ function convertFrontmatterAuthToConfig(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively expands ${PLUGIN_ROOT} in strings, arrays, and objects.
|
||||
*/
|
||||
function recursivelyExpandPluginRoot<T>(obj: T, pluginRoot: string): T {
|
||||
if (typeof obj === 'string') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.replace(/\${PLUGIN_ROOT}/g, pluginRoot) as unknown as T;
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return obj.map((item) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
recursivelyExpandPluginRoot(item, pluginRoot),
|
||||
) as unknown as T;
|
||||
}
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = recursivelyExpandPluginRoot(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(obj as Record<string, unknown>)[key],
|
||||
pluginRoot,
|
||||
);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return newObj as T;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a FrontmatterAgentDefinition DTO to the internal AgentDefinition structure.
|
||||
*
|
||||
* @param markdown The parsed Markdown/Frontmatter definition.
|
||||
* @param metadata Optional metadata including hash and file path.
|
||||
* @param metadata Optional metadata including hash, file path, and plugin root.
|
||||
* @returns The internal AgentDefinition.
|
||||
*/
|
||||
export function markdownToAgentDefinition(
|
||||
markdown: FrontmatterAgentDefinition,
|
||||
metadata?: { hash?: string; filePath?: string },
|
||||
metadata?: { hash?: string; filePath?: string; pluginRoot?: string },
|
||||
): AgentDefinition {
|
||||
const pluginRoot = metadata?.pluginRoot;
|
||||
const processedMarkdown =
|
||||
pluginRoot !== undefined
|
||||
? recursivelyExpandPluginRoot(markdown, pluginRoot)
|
||||
: markdown;
|
||||
|
||||
const inputConfig = {
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
@@ -514,15 +552,15 @@ export function markdownToAgentDefinition(
|
||||
},
|
||||
};
|
||||
|
||||
if (markdown.kind === 'remote') {
|
||||
if (processedMarkdown.kind === 'remote') {
|
||||
return {
|
||||
kind: 'remote',
|
||||
name: markdown.name,
|
||||
description: markdown.description || '',
|
||||
displayName: markdown.display_name,
|
||||
agentCardUrl: markdown.agent_card_url,
|
||||
auth: markdown.auth
|
||||
? convertFrontmatterAuthToConfig(markdown.auth)
|
||||
name: processedMarkdown.name,
|
||||
description: processedMarkdown.description || '',
|
||||
displayName: processedMarkdown.display_name,
|
||||
agentCardUrl: processedMarkdown.agent_card_url,
|
||||
auth: processedMarkdown.auth
|
||||
? convertFrontmatterAuthToConfig(processedMarkdown.auth)
|
||||
: undefined,
|
||||
inputConfig,
|
||||
metadata,
|
||||
@@ -530,11 +568,13 @@ export function markdownToAgentDefinition(
|
||||
}
|
||||
|
||||
// If a model is specified, use it. Otherwise, inherit
|
||||
const modelName = markdown.model || 'inherit';
|
||||
const modelName = processedMarkdown.model || 'inherit';
|
||||
|
||||
const mcpServers: Record<string, MCPServerConfig> = {};
|
||||
if (markdown.kind === 'local' && markdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(markdown.mcp_servers)) {
|
||||
if (processedMarkdown.kind === 'local' && processedMarkdown.mcp_servers) {
|
||||
for (const [name, config] of Object.entries(
|
||||
processedMarkdown.mcp_servers,
|
||||
)) {
|
||||
mcpServers[name] = new MCPServerConfig(
|
||||
config.command,
|
||||
config.args,
|
||||
@@ -556,27 +596,28 @@ export function markdownToAgentDefinition(
|
||||
|
||||
return {
|
||||
kind: 'local',
|
||||
name: markdown.name,
|
||||
description: markdown.description,
|
||||
displayName: markdown.display_name,
|
||||
name: processedMarkdown.name,
|
||||
description: processedMarkdown.description,
|
||||
displayName: processedMarkdown.display_name,
|
||||
promptConfig: {
|
||||
systemPrompt: markdown.system_prompt,
|
||||
systemPrompt: processedMarkdown.system_prompt,
|
||||
query: '${query}',
|
||||
},
|
||||
modelConfig: {
|
||||
model: modelName,
|
||||
generateContentConfig: {
|
||||
temperature: markdown.temperature ?? 1,
|
||||
temperature: processedMarkdown.temperature ?? 1,
|
||||
topP: 0.95,
|
||||
},
|
||||
},
|
||||
runConfig: {
|
||||
maxTurns: markdown.max_turns ?? DEFAULT_MAX_TURNS,
|
||||
maxTimeMinutes: markdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
|
||||
maxTurns: processedMarkdown.max_turns ?? DEFAULT_MAX_TURNS,
|
||||
maxTimeMinutes:
|
||||
processedMarkdown.timeout_mins ?? DEFAULT_MAX_TIME_MINUTES,
|
||||
},
|
||||
toolConfig: markdown.tools
|
||||
toolConfig: processedMarkdown.tools
|
||||
? {
|
||||
tools: markdown.tools,
|
||||
tools: processedMarkdown.tools,
|
||||
}
|
||||
: undefined,
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
@@ -591,10 +632,12 @@ export function markdownToAgentDefinition(
|
||||
* Supported extensions: .md
|
||||
*
|
||||
* @param dir Directory path to scan.
|
||||
* @param pluginRoot Optional plugin root path for variable expansion.
|
||||
* @returns Object containing successfully loaded agents and any errors.
|
||||
*/
|
||||
export async function loadAgentsFromDirectory(
|
||||
dir: string,
|
||||
pluginRoot?: string,
|
||||
): Promise<AgentLoadResult> {
|
||||
const result: AgentLoadResult = {
|
||||
agents: [],
|
||||
@@ -634,7 +677,11 @@ export async function loadAgentsFromDirectory(
|
||||
const hash = crypto.createHash('sha256').update(content).digest('hex');
|
||||
const agentDefs = await parseAgentMarkdown(filePath, content);
|
||||
for (const def of agentDefs) {
|
||||
const agent = markdownToAgentDefinition(def, { hash, filePath });
|
||||
const agent = markdownToAgentDefinition(def, {
|
||||
hash,
|
||||
filePath,
|
||||
pluginRoot,
|
||||
});
|
||||
result.agents.push(agent);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -129,6 +129,7 @@ export interface BaseAgentDefinition<
|
||||
metadata?: {
|
||||
hash?: string;
|
||||
filePath?: string;
|
||||
pluginRoot?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +348,10 @@ export interface GeminiCLIExtension {
|
||||
isActive: boolean;
|
||||
path: string;
|
||||
installMetadata?: ExtensionInstallMetadata;
|
||||
manifestType?: 'gemini' | 'open-plugin';
|
||||
description?: string;
|
||||
author?: string | { name: string; email?: string; url?: string };
|
||||
license?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
contextFiles: string[];
|
||||
excludeTools?: string[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -212,14 +212,20 @@ export class McpClientManager {
|
||||
*/
|
||||
async startExtension(extension: GeminiCLIExtension) {
|
||||
debugLogger.log(`Loading extension: ${extension.name}`);
|
||||
const mcpServers = Object.entries(extension.mcpServers ?? {});
|
||||
await Promise.all(
|
||||
Object.entries(extension.mcpServers ?? {}).map(([name, config]) =>
|
||||
this.maybeDiscoverMcpServer(name, {
|
||||
mcpServers.map(([name, config]) => {
|
||||
let serverName = name;
|
||||
if (extension.manifestType === 'open-plugin') {
|
||||
// Open Plugin MCP servers are prefixed with the plugin name using a colon.
|
||||
serverName = `${extension.name}:${name}`;
|
||||
}
|
||||
return this.maybeDiscoverMcpServer(serverName, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-spread
|
||||
...config,
|
||||
extension,
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
await this.scheduleMcpContextRefresh();
|
||||
}
|
||||
|
||||
@@ -2271,11 +2271,22 @@ export async function createTransport(
|
||||
}
|
||||
}
|
||||
|
||||
const expandedCommand = expandEnvVars(
|
||||
mcpServerConfig.command,
|
||||
expansionEnv,
|
||||
);
|
||||
const expandedArgs = (mcpServerConfig.args || []).map((arg) =>
|
||||
expandEnvVars(arg, expansionEnv),
|
||||
);
|
||||
const expandedCwd = mcpServerConfig.cwd
|
||||
? expandEnvVars(mcpServerConfig.cwd, expansionEnv)
|
||||
: undefined;
|
||||
|
||||
let transport: Transport = new StdioClientTransport({
|
||||
command: mcpServerConfig.command,
|
||||
args: mcpServerConfig.args || [],
|
||||
command: expandedCommand,
|
||||
args: expandedArgs,
|
||||
env: finalEnv,
|
||||
cwd: mcpServerConfig.cwd,
|
||||
cwd: expandedCwd,
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
@@ -2329,6 +2340,9 @@ function getExtensionEnvironment(
|
||||
extension?: GeminiCLIExtension,
|
||||
): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
if (extension?.path) {
|
||||
env['PLUGIN_ROOT'] = extension.path;
|
||||
}
|
||||
if (extension?.resolvedSettings) {
|
||||
for (const setting of extension.resolvedSettings) {
|
||||
if (setting.value !== undefined) {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import * as SdkClientStdioLib from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import { createTransport, type McpContext } from './mcp-client.js';
|
||||
import type { GeminiCLIExtension, MCPServerConfig } from '../config/config.js';
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js');
|
||||
|
||||
describe('MCP Plugin Variable Expansion', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockMcpContext: Partial<McpContext> = {
|
||||
sanitizationConfig: {
|
||||
allowedEnvironmentVariables: [],
|
||||
blockedEnvironmentVariables: [],
|
||||
enableEnvironmentVariableRedaction: false,
|
||||
},
|
||||
isTrustedFolder: () => true,
|
||||
emitMcpDiagnostic: () => {},
|
||||
};
|
||||
|
||||
it('should expand ${PLUGIN_ROOT} in command, args, and cwd', async () => {
|
||||
const mockExtension: GeminiCLIExtension = {
|
||||
name: 'test-plugin',
|
||||
path: '/path/to/plugin',
|
||||
isActive: true,
|
||||
id: 'test-id',
|
||||
version: '1.0.0',
|
||||
} as GeminiCLIExtension;
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
command: 'node',
|
||||
args: ['${PLUGIN_ROOT}/index.js'],
|
||||
cwd: '${PLUGIN_ROOT}/src',
|
||||
extension: mockExtension,
|
||||
};
|
||||
|
||||
await createTransport(
|
||||
'test-server',
|
||||
config,
|
||||
false,
|
||||
mockMcpContext as McpContext,
|
||||
);
|
||||
|
||||
expect(SdkClientStdioLib.StdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'node',
|
||||
args: ['/path/to/plugin/index.js'],
|
||||
cwd: '/path/to/plugin/src',
|
||||
env: expect.objectContaining({
|
||||
PLUGIN_ROOT: '/path/to/plugin',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should expand ${PLUGIN_ROOT} in env values', async () => {
|
||||
const mockExtension: GeminiCLIExtension = {
|
||||
name: 'test-plugin',
|
||||
path: '/path/to/plugin',
|
||||
isActive: true,
|
||||
id: 'test-id',
|
||||
version: '1.0.0',
|
||||
} as GeminiCLIExtension;
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
command: 'node',
|
||||
args: [],
|
||||
env: {
|
||||
PLUGIN_PATH: '${PLUGIN_ROOT}/lib',
|
||||
},
|
||||
extension: mockExtension,
|
||||
};
|
||||
|
||||
await createTransport(
|
||||
'test-server',
|
||||
config,
|
||||
false,
|
||||
mockMcpContext as McpContext,
|
||||
);
|
||||
|
||||
expect(SdkClientStdioLib.StdioClientTransport).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
PLUGIN_ROOT: '/path/to/plugin',
|
||||
PLUGIN_PATH: '/path/to/plugin/lib',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { McpClientManager } from './mcp-client-manager.js';
|
||||
import type {
|
||||
Config,
|
||||
GeminiCLIExtension,
|
||||
MCPServerConfig,
|
||||
} from '../config/config.js';
|
||||
|
||||
interface McpClientManagerInternals {
|
||||
maybeDiscoverMcpServer(name: string, config: MCPServerConfig): Promise<void>;
|
||||
}
|
||||
|
||||
describe('MCP Plugin Namespacing', () => {
|
||||
let mcpClientManager: McpClientManager;
|
||||
let mockConfig: Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockConfig = {
|
||||
isTrustedFolder: () => true,
|
||||
getMcpServers: () => ({}),
|
||||
getMcpServerCommand: () => undefined,
|
||||
getMcpEnablementCallbacks: () => undefined,
|
||||
getAllowedMcpServers: () => [],
|
||||
getBlockedMcpServers: () => [],
|
||||
getDebugMode: () => false,
|
||||
getWorkspaceContext: () => ({}),
|
||||
} as unknown as Config;
|
||||
mcpClientManager = new McpClientManager('1.0.0', mockConfig);
|
||||
});
|
||||
|
||||
it('should use pluginName:mcpServerName for single MCP server in Open Plugin', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'my-plugin',
|
||||
manifestType: 'open-plugin',
|
||||
mcpServers: {
|
||||
'original-name': {
|
||||
command: 'node',
|
||||
args: ['index.js'],
|
||||
},
|
||||
},
|
||||
id: 'test-id',
|
||||
isActive: true,
|
||||
} as unknown as GeminiCLIExtension;
|
||||
|
||||
const maybeDiscoverSpy = vi
|
||||
.spyOn(
|
||||
mcpClientManager as unknown as McpClientManagerInternals,
|
||||
'maybeDiscoverMcpServer',
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await mcpClientManager.startExtension(extension);
|
||||
|
||||
expect(maybeDiscoverSpy).toHaveBeenCalledWith(
|
||||
'my-plugin:original-name',
|
||||
expect.objectContaining({
|
||||
command: 'node',
|
||||
extension,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use pluginName:mcpServerName for multiple MCP servers in Open Plugin', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'multi-plugin',
|
||||
manifestType: 'open-plugin',
|
||||
mcpServers: {
|
||||
s1: { command: 'node', args: ['s1.js'] },
|
||||
s2: { command: 'node', args: ['s2.js'] },
|
||||
},
|
||||
id: 'test-id',
|
||||
isActive: true,
|
||||
} as unknown as GeminiCLIExtension;
|
||||
|
||||
const maybeDiscoverSpy = vi
|
||||
.spyOn(
|
||||
mcpClientManager as unknown as McpClientManagerInternals,
|
||||
'maybeDiscoverMcpServer',
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await mcpClientManager.startExtension(extension);
|
||||
|
||||
expect(maybeDiscoverSpy).toHaveBeenCalledWith(
|
||||
'multi-plugin:s1',
|
||||
expect.objectContaining({
|
||||
command: 'node',
|
||||
extension,
|
||||
}),
|
||||
);
|
||||
expect(maybeDiscoverSpy).toHaveBeenCalledWith(
|
||||
'multi-plugin:s2',
|
||||
expect.objectContaining({
|
||||
command: 'node',
|
||||
extension,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should NOT use plugin name for Gemini extensions', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'gemini-extension',
|
||||
manifestType: 'gemini',
|
||||
mcpServers: {
|
||||
'original-name': {
|
||||
command: 'node',
|
||||
args: ['index.js'],
|
||||
},
|
||||
},
|
||||
id: 'test-id',
|
||||
isActive: true,
|
||||
} as unknown as GeminiCLIExtension;
|
||||
|
||||
const maybeDiscoverSpy = vi
|
||||
.spyOn(
|
||||
mcpClientManager as unknown as McpClientManagerInternals,
|
||||
'maybeDiscoverMcpServer',
|
||||
)
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
await mcpClientManager.startExtension(extension);
|
||||
|
||||
expect(maybeDiscoverSpy).toHaveBeenCalledWith(
|
||||
'original-name',
|
||||
expect.objectContaining({
|
||||
command: 'node',
|
||||
extension,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
isMcpToolName,
|
||||
parseMcpToolName,
|
||||
generateValidName,
|
||||
formatMcpToolName,
|
||||
} from './mcp-tool.js';
|
||||
|
||||
describe('MCP Plugin Naming', () => {
|
||||
describe('isMcpToolName', () => {
|
||||
it('should identify standard MCP tool names', () => {
|
||||
expect(isMcpToolName('mcp_server_tool')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseMcpToolName', () => {
|
||||
it('should parse standard MCP tool names', () => {
|
||||
const result = parseMcpToolName('mcp_server_tool');
|
||||
expect(result.serverName).toBe('server');
|
||||
expect(result.toolName).toBe('tool');
|
||||
});
|
||||
|
||||
it('should parse MCP names with colons in the server name', () => {
|
||||
// This is the format for Open Plugins: mcp_plugin:server_tool
|
||||
const result = parseMcpToolName('mcp_demo-plugin:demo-server_demo_tool');
|
||||
expect(result.serverName).toBe('demo-plugin:demo-server');
|
||||
expect(result.toolName).toBe('demo_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateValidName', () => {
|
||||
it('should add mcp_ prefix to standard names', () => {
|
||||
expect(generateValidName('server_tool')).toBe('mcp_server_tool');
|
||||
});
|
||||
|
||||
it('should include colons if they are in the input', () => {
|
||||
expect(generateValidName('demo-plugin:demo-server_demo_tool')).toBe(
|
||||
'mcp_demo-plugin:demo-server_demo_tool',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMcpToolName', () => {
|
||||
it('should format standard MCP tool names', () => {
|
||||
expect(formatMcpToolName('server', 'tool')).toBe('mcp_server_tool');
|
||||
});
|
||||
|
||||
it('should format Open Plugin namespaced tool names using underscores for the tool portion', () => {
|
||||
expect(formatMcpToolName('plugin:server', 'tool')).toBe(
|
||||
'mcp_plugin:server_tool',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user