From 5cb7419fc21f0d8724100e6938b884592ad52b61 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Fri, 3 Apr 2026 16:56:43 -0400 Subject: [PATCH] feat(cli): update open plugin manifest discovery and enhance extension metadata - https://github.com/vercel-labs/open-plugin-spec/tree/main --- packages/cli/src/config/extension-manager.ts | 3 + packages/cli/src/config/extension.ts | 61 ++++++------- .../cli/src/config/extensions/variables.ts | 3 +- .../src/config/open-plugin-discovery.test.ts | 90 ++++++++++++++++--- packages/cli/src/config/plugin.ts | 72 ++++++++++----- packages/core/src/config/config.ts | 3 + 6 files changed, 158 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 87196ece55..3f15ac1e1b 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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)}`; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 5826965af0..ff0c516440 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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; - 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()).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()).optional(), + /** + * Custom themes contributed by this extension. + * These themes will be registered when the extension is activated. + */ + themes: z.array(z.custom()).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 & { + manifestType?: 'gemini' | 'open-plugin'; + keywords?: string[]; + homepage?: string; + repository?: string; +}; + export interface ExtensionUpdateInfo { name: string; originalVersion: string; diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 7b45611c03..50bac3a396 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -20,8 +20,7 @@ const UNMARSHALL_KEY_IGNORE_LIST: Set = new Set([ 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', ); diff --git a/packages/cli/src/config/open-plugin-discovery.test.ts b/packages/cli/src/config/open-plugin-discovery.test.ts index 5496527872..62dfb14c37 100644 --- a/packages/cli/src/config/open-plugin-discovery.test.ts +++ b/packages/cli/src/config/open-plugin-discovery.test.ts @@ -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'); + }); }); diff --git a/packages/cli/src/config/plugin.ts b/packages/cli/src/config/plugin.ts index 5556e5db6f..15c6d742fa 100644 --- a/packages/cli/src/config/plugin.ts +++ b/packages/cli/src/config/plugin.ts @@ -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; - agents?: string[] | Record; - hooks?: string[] | Record; - mcpServers?: string[] | Record; + skills?: OpenPluginDiscoveryField; + mcpServers?: OpenPluginDiscoveryField | Record; } -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, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 624d0e631d..dd2027f60d 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; contextFiles: string[]; excludeTools?: string[];