mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-15 06:12:50 -07:00
feat(cli): update open plugin manifest discovery and enhance extension metadata
- https://github.com/vercel-labs/open-plugin-spec/tree/main
This commit is contained in:
@@ -1145,6 +1145,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)}`;
|
||||
|
||||
|
||||
@@ -30,40 +30,9 @@ import {
|
||||
* outside of the loading process that data needs to be stored on the
|
||||
* GeminiCLIExtension class defined in Core.
|
||||
*/
|
||||
export interface ExtensionConfig {
|
||||
name: string;
|
||||
version: string;
|
||||
manifestType?: 'gemini' | 'open-plugin';
|
||||
description?: string;
|
||||
author?: string | { name: string; email?: string; url?: string };
|
||||
license?: string;
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
contextFileName?: string | string[];
|
||||
excludeTools?: string[];
|
||||
settings?: ExtensionSetting[];
|
||||
/**
|
||||
* Custom themes contributed by this extension.
|
||||
* These themes will be registered when the extension is activated.
|
||||
*/
|
||||
themes?: CustomTheme[];
|
||||
/**
|
||||
* Planning features configuration contributed by this extension.
|
||||
*/
|
||||
plan?: {
|
||||
/**
|
||||
* The directory where planning artifacts are stored.
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
}
|
||||
|
||||
export const geminiExtensionSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
version: z.string().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
version: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
author: z
|
||||
.union([
|
||||
@@ -76,19 +45,39 @@ export const geminiExtensionSchema = z.object({
|
||||
])
|
||||
.optional(),
|
||||
license: z.string().optional(),
|
||||
mcpServers: z.record(z.any()).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.any()).optional(),
|
||||
themes: z.array(z.any()).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: z.array(z.custom<CustomTheme>()).optional(),
|
||||
/**
|
||||
* Planning features configuration contributed by this extension.
|
||||
*/
|
||||
plan: z
|
||||
.object({
|
||||
directory: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
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;
|
||||
originalVersion: string;
|
||||
|
||||
@@ -20,8 +20,7 @@ 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(
|
||||
export const STANDARD_OPEN_PLUGIN_CONFIG_FILENAME = path.join(
|
||||
'.plugin',
|
||||
'plugin.json',
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should discover a plugin with plugin.json', async () => {
|
||||
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(
|
||||
@@ -79,19 +79,13 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
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');
|
||||
expect(plugin).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should discover a plugin with .plugin/plugin.json', async () => {
|
||||
@@ -116,10 +110,11 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
|
||||
it('should support PLUGIN_ROOT variable alias in metadata', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'var-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'var-plugin',
|
||||
version: '1.0.0',
|
||||
@@ -136,12 +131,13 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
|
||||
it('should NOT load skills or context files for Open Plugins in v1', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'feature-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
const skillsDir = path.join(pluginDir, 'skills', 'test');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'feature-plugin',
|
||||
version: '1.0.0',
|
||||
@@ -169,7 +165,8 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
|
||||
it('should prioritize gemini-extension.json over plugin.json', async () => {
|
||||
const pluginDir = path.join(userExtensionsDir, 'dual-manifest-plugin');
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
const hiddenDir = path.join(pluginDir, '.plugin');
|
||||
fs.mkdirSync(hiddenDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'gemini-extension.json'),
|
||||
JSON.stringify({
|
||||
@@ -178,7 +175,7 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, 'plugin.json'),
|
||||
path.join(hiddenDir, 'plugin.json'),
|
||||
JSON.stringify({
|
||||
name: 'open-plugin',
|
||||
version: '2.2.2',
|
||||
@@ -195,4 +192,69 @@ describe('ExtensionManager - Open Plugin Support', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,7 @@ import type {
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
HIDDEN_OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
STANDARD_OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
recursivelyHydrateStrings,
|
||||
type JsonObject,
|
||||
} from './extensions/variables.js';
|
||||
@@ -30,18 +29,45 @@ export interface OpenPluginConfig {
|
||||
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?: string[] | Record<string, unknown>;
|
||||
agents?: string[] | Record<string, unknown>;
|
||||
hooks?: string[] | Record<string, unknown>;
|
||||
mcpServers?: string[] | Record<string, unknown>;
|
||||
skills?: OpenPluginDiscoveryField;
|
||||
mcpServers?: OpenPluginDiscoveryField | Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const OPEN_PLUGIN_NAME_REGEX = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
||||
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().min(1).max(64).regex(OPEN_PLUGIN_NAME_REGEX),
|
||||
version: z.string().optional(),
|
||||
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([
|
||||
@@ -54,10 +80,11 @@ export const openPluginSchema = z.object({
|
||||
])
|
||||
.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.array(z.string()), z.record(z.any())]).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 {
|
||||
@@ -71,17 +98,12 @@ export function findManifest(extensionDir: string): ManifestInfo | undefined {
|
||||
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(
|
||||
const standardOpenPluginPath = path.join(
|
||||
extensionDir,
|
||||
HIDDEN_OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
STANDARD_OPEN_PLUGIN_CONFIG_FILENAME,
|
||||
);
|
||||
if (fs.existsSync(hiddenOpenPluginPath)) {
|
||||
return { type: 'open-plugin', path: hiddenOpenPluginPath };
|
||||
if (fs.existsSync(standardOpenPluginPath)) {
|
||||
return { type: 'open-plugin', path: standardOpenPluginPath };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -125,6 +147,9 @@ export async function loadOpenPluginConfig(
|
||||
description: hydratedConfig.description,
|
||||
author: hydratedConfig.author,
|
||||
license: hydratedConfig.license,
|
||||
keywords: hydratedConfig.keywords,
|
||||
homepage: hydratedConfig.homepage,
|
||||
repository: hydratedConfig.repository,
|
||||
// Features are explicitly NOT mapped here for v1 plugins
|
||||
};
|
||||
}
|
||||
@@ -159,6 +184,9 @@ export async function createOpenPlugin(
|
||||
description: config.description,
|
||||
author: config.author,
|
||||
license: config.license,
|
||||
keywords: config.keywords,
|
||||
homepage: config.homepage,
|
||||
repository: config.repository,
|
||||
// v1: Features disabled
|
||||
contextFiles: [],
|
||||
mcpServers: undefined,
|
||||
|
||||
@@ -386,6 +386,9 @@ export interface GeminiCLIExtension {
|
||||
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[];
|
||||
|
||||
Reference in New Issue
Block a user