Compare commits

...

8 Commits

Author SHA1 Message Date
ruomeng 5009a99754 feat(cli): support custom skill discovery paths for Open Plugins 2026-04-14 13:59:52 -04:00
Taylor Mullen 5dd6a00ce8 feat(cli): support skill discovery for Open Plugins
- Updated skillLoader to support discovery in skills/ subdirectories
- Implemented convention-based skill discovery for Open Plugins
- Enforced namespacing for plugin skills (plugin:skill-name)
- Refactored skill resolution into resolvePluginSkills for better maintainability
- Added comprehensive tests for Open Plugin skill discovery

Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1592
2026-04-03 17:36:48 -04:00
ruomeng 5cb7419fc2 feat(cli): update open plugin manifest discovery and enhance extension metadata
- https://github.com/vercel-labs/open-plugin-spec/tree/main
2026-04-03 17:12:32 -04:00
Taylor Mullen a565774122 refactor(cli): further trim open-plugin schema and core extension interface 2026-04-03 16:58:12 -04:00
Taylor Mullen ec432f0954 fix(cli): update extension name validation test to match new error message 2026-04-03 16:58:12 -04:00
Taylor Mullen 4a57795abb chore(test): add cleanup to open-plugin-discovery tests 2026-04-03 16:58:12 -04:00
Taylor Mullen 10992dd9f9 fix(cli): pass workspaceDir to createOpenPlugin to ensure correct variable hydration 2026-04-03 16:58:12 -04:00
Taylor Mullen cee98aee89 feat(cli): support Open Plugin (plugin.json) manifest standard
Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1597
2026-04-03 16:58:12 -04:00
12 changed files with 1058 additions and 97 deletions
@@ -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);
+120 -59
View File
@@ -11,7 +11,21 @@ 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,
resolvePluginSkills,
type OpenPluginDiscoveryField,
} from './plugin.js';
import {
isWorkspaceTrusted,
loadTrustedFolders,
@@ -39,6 +53,7 @@ import {
logExtensionUninstall,
logExtensionUpdateEvent,
loadSkillsFromDir,
type SkillDefinition,
loadAgentsFromDirectory,
homedir,
ExtensionIntegrityManager,
@@ -65,7 +80,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 +307,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(
@@ -328,9 +346,30 @@ Would you like to attempt to install via "git clone" instead?`,
Object.keys(previous.hooks).length > 0
);
const newSkills = await loadSkillsFromDir(
path.join(localSourcePath, 'skills'),
);
let newSkills: SkillDefinition[] = [];
if (newExtensionConfig.manifestType === 'open-plugin') {
const openPluginConfig = newExtensionConfig as OpenPluginConfig & {
skills?: OpenPluginDiscoveryField;
};
const hydrationContext = {
extensionPath: localSourcePath,
PLUGIN_ROOT: localSourcePath,
workspacePath: this.workspaceDir,
'/': path.sep,
pathSeparator: path.sep,
};
const resolvedSkills = await resolvePluginSkills(
localSourcePath,
newExtensionConfig.name,
hydrationContext,
openPluginConfig.skills,
);
newSkills = resolvedSkills || [];
} else {
newSkills = await loadSkillsFromDir(
path.join(localSourcePath, 'skills'),
);
}
const previousSkills = previous?.skills ?? [];
const isMigrating = Boolean(
previous &&
@@ -757,23 +796,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 +857,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 +950,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,
@@ -957,30 +1034,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 +1083,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;
}
}
@@ -1111,6 +1169,9 @@ Would you like to attempt to install via "git clone" instead?`,
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.name} (${extension.version})`;
if (extension.manifestType) {
output += ` [${extension.manifestType === 'open-plugin' ? 'Open Plugin' : 'Gemini Extension'}]`;
}
output += `\n ID: ${extension.id}`;
output += `\n name: ${hashValue(extension.name)}`;
@@ -1279,9 +1340,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.`,
);
}
}
@@ -1328,7 +1389,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
+70 -10
View File
@@ -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']);
});
});
+131 -21
View File
@@ -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.
@@ -21,32 +30,53 @@ import type { ExtensionSetting } from './extensions/extensionSettings.js';
* 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;
mcpServers?: Record<string, MCPServerConfig>;
contextFileName?: string | string[];
excludeTools?: string[];
settings?: ExtensionSetting[];
export const geminiExtensionSchema = z.object({
name: z.string().trim().min(1),
version: z.string().trim().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.custom<MCPServerConfig>()).optional(),
contextFileName: z.union([z.string(), z.array(z.string())]).optional(),
excludeTools: z.array(z.string()).optional(),
settings: z.array(z.custom<ExtensionSetting>()).optional(),
/**
* Custom themes contributed by this extension.
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
themes: z.array(z.custom<CustomTheme>()).optional(),
/**
* Planning features configuration contributed by this extension.
*/
plan?: {
/**
* The directory where planning artifacts are stored.
*/
directory?: string;
};
plan: z
.object({
directory: z.string().optional(),
})
.optional(),
/**
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
}
migratedTo: z.string().optional(),
});
/**
* Internal representation of an extension configuration after being loaded and validated.
*/
export type ExtensionConfig = z.infer<typeof geminiExtensionSchema> & {
manifestType?: 'gemini' | 'open-plugin';
keywords?: string[];
homepage?: string;
repository?: string;
};
export interface ExtensionUpdateInfo {
name: string;
@@ -67,3 +97,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,10 @@ 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 STANDARD_OPEN_PLUGIN_CONFIG_FILENAME = path.join(
'.plugin',
'plugin.json',
);
export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
export const EXTENSION_SETTINGS_FILENAME = '.env';
@@ -0,0 +1,414 @@
/**
* @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 NOT discover a plugin with plugin.json at root', 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',
}),
);
const extensions = await extensionManager.loadExtensions();
const plugin = extensions.find((ext) => ext.name === 'hello-world');
expect(plugin).toBeUndefined();
});
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');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, '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');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
const skillsDir = path.join(pluginDir, 'skills', 'test-skill');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, '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 load skills from custom path specified as string', async () => {
const pluginDir = path.join(userExtensionsDir, 'custom-path-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
const skillsDir = path.join(pluginDir, 'custom-skills', 'test-skill');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'custom-path-plugin',
version: '1.0.0',
skills: './custom-skills',
}),
);
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 === 'custom-path-plugin');
expect(plugin).toBeDefined();
expect(plugin?.skills).toBeDefined();
expect(plugin?.skills?.[0].name).toBe('custom-path-plugin:my-skill');
});
it('should load skills from custom paths specified as array', async () => {
const pluginDir = path.join(userExtensionsDir, 'array-path-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
const skillsDir1 = path.join(pluginDir, 'skills1', 'skill1');
const skillsDir2 = path.join(pluginDir, 'skills2', 'skill2');
fs.mkdirSync(skillsDir1, { recursive: true });
fs.mkdirSync(skillsDir2, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'array-path-plugin',
version: '1.0.0',
skills: ['./skills1', './skills2'],
}),
);
fs.writeFileSync(
path.join(skillsDir1, 'SKILL.md'),
`---
name: skill1
description: "Desc 1"
---
Body`,
);
fs.writeFileSync(
path.join(skillsDir2, 'SKILL.md'),
`---
name: skill2
description: "Desc 2"
---
Body`,
);
const extensions = await extensionManager.loadExtensions();
const plugin = extensions.find((ext) => ext.name === 'array-path-plugin');
expect(plugin).toBeDefined();
expect(plugin?.skills).toHaveLength(2);
const skillNames = plugin?.skills?.map((s) => s.name);
expect(skillNames).toContain('array-path-plugin:skill1');
expect(skillNames).toContain('array-path-plugin:skill2');
});
it('should load skills from custom paths specified as object', async () => {
const pluginDir = path.join(userExtensionsDir, 'object-path-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
const skillsDir = path.join(pluginDir, 'obj-skills', 'skill');
fs.mkdirSync(skillsDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'object-path-plugin',
version: '1.0.0',
skills: { paths: ['./obj-skills'] },
}),
);
fs.writeFileSync(
path.join(skillsDir, 'SKILL.md'),
`---
name: skill1
description: "Desc 1"
---
Body`,
);
const extensions = await extensionManager.loadExtensions();
const plugin = extensions.find((ext) => ext.name === 'object-path-plugin');
expect(plugin).toBeDefined();
expect(plugin?.skills?.[0].name).toBe('object-path-plugin:skill1');
});
it('should NOT load skills from default location if custom paths are specified', async () => {
const pluginDir = path.join(userExtensionsDir, 'override-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
const defaultDir = path.join(pluginDir, 'skills', 'skill1');
const customDir = path.join(pluginDir, 'custom-skills', 'skill2');
fs.mkdirSync(defaultDir, { recursive: true });
fs.mkdirSync(customDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'override-plugin',
version: '1.0.0',
skills: './custom-skills',
}),
);
fs.writeFileSync(
path.join(defaultDir, 'SKILL.md'),
`---
name: skill1
description: "Default"
---
Body`,
);
fs.writeFileSync(
path.join(customDir, 'SKILL.md'),
`---
name: skill2
description: "Custom"
---
Body`,
);
const extensions = await extensionManager.loadExtensions();
const plugin = extensions.find((ext) => ext.name === 'override-plugin');
expect(plugin).toBeDefined();
expect(plugin?.skills).toHaveLength(1);
expect(plugin?.skills?.[0].name).toBe('override-plugin:skill2');
});
it('should prioritize gemini-extension.json over plugin.json', async () => {
const pluginDir = path.join(userExtensionsDir, 'dual-manifest-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, 'gemini-extension.json'),
JSON.stringify({
name: 'gemini-plugin',
version: '1.1.1',
}),
);
fs.writeFileSync(
path.join(hiddenDir, '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();
});
it('should support new metadata and discovery fields with path validation', async () => {
const pluginDir = path.join(userExtensionsDir, 'new-spec-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'new-spec-plugin',
version: '1.0.0',
keywords: ['test', 'plugin'],
homepage: 'https://example.com',
repository: 'https://github.com/example/plugin',
skills: './skills',
}),
);
const extensions = await extensionManager.loadExtensions();
const plugin = extensions.find((ext) => ext.name === 'new-spec-plugin');
expect(plugin).toBeDefined();
expect(plugin?.keywords).toEqual(['test', 'plugin']);
expect(plugin?.homepage).toBe('https://example.com');
expect(plugin?.repository).toBe('https://github.com/example/plugin');
expect(plugin?.manifestType).toBe('open-plugin');
});
it('should fail validation if discovery path does not start with ./', async () => {
const pluginDir = path.join(userExtensionsDir, 'invalid-path-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'invalid-path-plugin',
version: '1.0.0',
skills: 'skills', // Missing ./
}),
);
await expect(
extensionManager.loadExtensionConfig(pluginDir),
).rejects.toThrow('Invalid plugin.json');
});
it('should fail validation if discovery path contains ../', async () => {
const pluginDir = path.join(userExtensionsDir, 'traversal-plugin');
const hiddenDir = path.join(pluginDir, '.plugin');
fs.mkdirSync(hiddenDir, { recursive: true });
fs.writeFileSync(
path.join(hiddenDir, 'plugin.json'),
JSON.stringify({
name: 'traversal-plugin',
version: '1.0.0',
skills: './../outside', // Contains ../
}),
);
await expect(
extensionManager.loadExtensionConfig(pluginDir),
).rejects.toThrow('Invalid plugin.json');
});
});
+261
View File
@@ -0,0 +1,261 @@
/**
* @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,
type ExtensionInstallMetadata,
type GeminiCLIExtension,
type SkillDefinition,
} from '@google/gemini-cli-core';
import {
EXTENSIONS_CONFIG_FILENAME,
STANDARD_OPEN_PLUGIN_CONFIG_FILENAME,
recursivelyHydrateStrings,
type JsonObject,
} 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;
keywords?: string[];
homepage?: string;
repository?: string;
// Component fields (parsed but currently ignored during execution per v1 plan)
skills?: OpenPluginDiscoveryField;
mcpServers?: OpenPluginDiscoveryField | Record<string, unknown>;
}
export type OpenPluginDiscoveryField = string | string[] | { paths: string[] };
export const OPEN_PLUGIN_NAME_REGEX = /^[a-z0-9.-]{1,64}$/;
/**
* Validates that a discovery path starts with ./ and does not contain ../
*/
const isValidDiscoveryPath = (p: string) =>
p.startsWith('./') && !p.includes('../');
const discoveryFieldSchema = z.union([
z.string().refine(isValidDiscoveryPath, {
message: 'Path must start with "./" and cannot contain "../"',
}),
z.array(
z.string().refine(isValidDiscoveryPath, {
message: 'Path must start with "./" and cannot contain "../"',
}),
),
z.object({
paths: z.array(
z.string().refine(isValidDiscoveryPath, {
message: 'Path must start with "./" and cannot contain "../"',
}),
),
}),
]);
export const openPluginSchema = z.object({
name: z.string().trim().min(1).max(64).regex(OPEN_PLUGIN_NAME_REGEX),
version: z.string().trim().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(),
keywords: z.array(z.string()).optional(),
homepage: z.string().url().optional(),
repository: z.string().url().optional(),
skills: discoveryFieldSchema.optional(),
mcpServers: z.union([discoveryFieldSchema, z.record(z.any())]).optional(),
});
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 standardOpenPluginPath = path.join(
extensionDir,
STANDARD_OPEN_PLUGIN_CONFIG_FILENAME,
);
if (fs.existsSync(standardOpenPluginPath)) {
return { type: 'open-plugin', path: standardOpenPluginPath };
}
return undefined;
}
/**
* Loads an Open Plugin manifest and maps it to ExtensionConfig.
*/
export async function loadOpenPluginConfig(
manifestPath: string,
extensionDir: string,
workspaceDir: string,
): Promise<ExtensionConfig & { skills?: OpenPluginDiscoveryField }> {
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
// 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;
return {
name: hydratedConfig.name,
version: hydratedConfig.version ?? '0.0.0',
manifestType: 'open-plugin',
description: hydratedConfig.description,
author: hydratedConfig.author,
license: hydratedConfig.license,
keywords: hydratedConfig.keywords,
homepage: hydratedConfig.homepage,
repository: hydratedConfig.repository,
skills: hydratedConfig.skills,
// Features are explicitly NOT mapped here for v1 plugins
};
}
/**
* 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,
config.skills,
);
return {
name: config.name,
version: config.version,
path: pluginDir,
isActive,
id,
installMetadata,
manifestType: 'open-plugin',
description: config.description,
author: config.author,
license: config.license,
keywords: config.keywords,
homepage: config.homepage,
repository: config.repository,
// v1: Features disabled
contextFiles: [],
mcpServers: undefined,
excludeTools: undefined,
settings: undefined,
resolvedSettings: undefined,
skills,
agents: undefined,
themes: undefined,
};
}
/**
* Discovers and namespaces skills for an Open Plugin.
*/
export async function resolvePluginSkills(
pluginDir: string,
pluginName: string,
hydrationContext: Record<string, string>,
skillsConfig?: OpenPluginDiscoveryField,
): Promise<GeminiCLIExtension['skills']> {
let resolvedPaths: string[] = [];
if (skillsConfig) {
if (typeof skillsConfig === 'string') {
resolvedPaths = [skillsConfig];
} else if (Array.isArray(skillsConfig)) {
resolvedPaths = skillsConfig;
} else if (typeof skillsConfig === 'object' && 'paths' in skillsConfig) {
resolvedPaths = skillsConfig.paths;
}
} else {
resolvedPaths = ['./skills/'];
}
const allDiscoveredSkills: SkillDefinition[] = [];
for (const relPath of resolvedPaths) {
const absPath = path.resolve(pluginDir, relPath);
if (fs.existsSync(absPath)) {
const discovered = await loadSkillsFromDir(absPath);
allDiscoveredSkills.push(...discovered);
}
}
if (allDiscoveredSkills.length === 0) {
return undefined;
}
return allDiscoveredSkills.map((skill) => ({
...recursivelyHydrateStrings(skill, hydrationContext),
name: `${pluginName}:${skill.name}`,
extensionName: pluginName,
}));
}
@@ -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>
+7
View File
@@ -382,6 +382,13 @@ 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;
keywords?: string[];
homepage?: string;
repository?: string;
mcpServers?: Record<string, MCPServerConfig>;
contextFiles: string[];
excludeTools?: string[];