From cee98aee89903e455e0a56d1aea1251ad78b65bd Mon Sep 17 00:00:00 2001 From: Taylor Mullen Date: Mon, 23 Mar 2026 15:59:18 -0700 Subject: [PATCH] feat(cli): support Open Plugin (plugin.json) manifest standard Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1597 --- packages/cli/src/config/extension-manager.ts | 145 +++++++----- packages/cli/src/config/extension.test.ts | 80 ++++++- packages/cli/src/config/extension.ts | 152 ++++++++++++- .../src/config/extensions/variableSchema.ts | 4 + .../cli/src/config/extensions/variables.ts | 5 + .../src/config/open-plugin-discovery.test.ts | 194 ++++++++++++++++ packages/cli/src/config/plugin.ts | 209 ++++++++++++++++++ .../ui/components/views/ExtensionsList.tsx | 29 ++- packages/core/src/config/config.ts | 8 + 9 files changed, 750 insertions(+), 76 deletions(-) create mode 100644 packages/cli/src/config/open-plugin-discovery.test.ts create mode 100644 packages/cli/src/config/plugin.ts diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 65b3539794..3a6006afe0 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -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,58 @@ 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, + 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 = {}; let workspaceSettings: Record = {}; 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 +832,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 +925,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 +1009,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 +1058,27 @@ Would you like to attempt to install via "git clone" instead?`, } async loadExtensionConfig(extensionDir: string): Promise { - 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; } } @@ -1279,9 +1312,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 +1361,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 diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index ef7e61cf25..38dfdd2d7f 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -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']); + }); +}); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 20a7073464..c270bc2b8c 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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,6 +33,14 @@ 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; + repository?: string | { type: string; url: string; directory?: string }; + homepage?: string; + logo?: string; + keywords?: string[]; mcpServers?: Record; contextFileName?: string | string[]; excludeTools?: string[]; @@ -48,6 +65,47 @@ 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(), + repository: z + .union([ + z.string(), + z.object({ + type: z.string(), + url: z.string(), + directory: z.string().optional(), + }), + ]) + .optional(), + homepage: z.string().url().optional(), + logo: z.string().optional(), + keywords: z.array(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(), + 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 +125,87 @@ export function loadInstallMetadata( return undefined; } } + +/** + * Loads a Gemini CLI extension manifest. + */ +export async function loadGeminiConfig( + manifestPath: string, + extensionDir: string, + workspaceDir: string, +): Promise { + 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, + repository: config.repository ?? config.migratedTo, + homepage: config.homepage, + logo: config.logo, + keywords: config.keywords, + 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, + }; +} diff --git a/packages/cli/src/config/extensions/variableSchema.ts b/packages/cli/src/config/extensions/variableSchema.ts index 0b8abc5021..ae73643918 100644 --- a/packages/cli/src/config/extensions/variableSchema.ts +++ b/packages/cli/src/config/extensions/variableSchema.ts @@ -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.', diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index b5b14c9643..7b45611c03 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -20,6 +20,11 @@ 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( + '.plugin', + 'plugin.json', +); export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; export const EXTENSION_SETTINGS_FILENAME = '.env'; diff --git a/packages/cli/src/config/open-plugin-discovery.test.ts b/packages/cli/src/config/open-plugin-discovery.test.ts new file mode 100644 index 0000000000..c2a1830cf8 --- /dev/null +++ b/packages/cli/src/config/open-plugin-discovery.test.ts @@ -0,0 +1,194 @@ +/** + * @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(); + return { + ...mockedOs, + homedir: mockHomedir, + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + 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, + }); + }); + + 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'); + expect(plugin?.author).toEqual({ name: 'Taylor' }); + expect(plugin?.license).toBe('Apache-2.0'); + }); + + 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 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 skillsDir = path.join(pluginDir, 'skills', 'test'); + 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: test-skill + description: "Test" + --- + Body`, + ); + + fs.writeFileSync(path.join(pluginDir, 'GEMINI.md'), '# Context'); + + const extensions = await extensionManager.loadExtensions(); + const plugin = extensions.find((ext) => ext.name === 'feature-plugin'); + + expect(plugin).toBeDefined(); + expect(plugin?.skills).toBeUndefined(); + expect(plugin?.contextFiles).toEqual([]); + }); + + 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(); + }); +}); diff --git a/packages/cli/src/config/plugin.ts b/packages/cli/src/config/plugin.ts new file mode 100644 index 0000000000..d30831912c --- /dev/null +++ b/packages/cli/src/config/plugin.ts @@ -0,0 +1,209 @@ +/** + * @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 type { + ExtensionInstallMetadata, + GeminiCLIExtension, + CustomTheme, +} from '@google/gemini-cli-core'; +import { + EXTENSIONS_CONFIG_FILENAME, + HIDDEN_OPEN_PLUGIN_CONFIG_FILENAME, + OPEN_PLUGIN_CONFIG_FILENAME, + recursivelyHydrateStrings, + type JsonObject, +} from './extensions/variables.js'; +import type { ExtensionConfig } from './extension.js'; +import type { ExtensionSetting } from './extensions/extensionSettings.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; + repository?: string | { type: string; url: string; directory?: string }; + homepage?: string; + logo?: string; + keywords?: string[]; + // Component fields (parsed but currently ignored during execution per v1 plan) + skills?: string[] | Record; + agents?: string[] | Record; + hooks?: string[] | Record; + mcpServers?: string[] | Record; + lspServers?: string[] | Record; + rules?: string[] | Record; + // For Gemini CLI compatibility + settings?: ExtensionSetting[]; + themes?: CustomTheme[]; +} + +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(), + repository: z + .union([ + z.string(), + z.object({ + type: z.string(), + url: z.string(), + directory: z.string().optional(), + }), + ]) + .optional(), + homepage: z.string().url().optional(), + logo: z.string().optional(), + keywords: z.array(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(), + lspServers: z.union([z.array(z.string()), z.record(z.any())]).optional(), + rules: z.union([z.array(z.string()), z.record(z.any())]).optional(), + settings: z.array(z.any()).optional(), + themes: z.array(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 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 { + 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, + repository: hydratedConfig.repository, + homepage: hydratedConfig.homepage, + logo: hydratedConfig.logo, + keywords: hydratedConfig.keywords, + settings: hydratedConfig.settings, + themes: hydratedConfig.themes, + // Features are explicitly NOT mapped here for v1 plugins + }; +} + +/** + * Creates a GeminiCLIExtension from an Open Plugin directory. + * v1: Does not enable skills, mcp servers, context files, or settings. + */ +export async function createOpenPlugin( + pluginDir: string, + manifestPath: string, + isActive: boolean, + id: string, + installMetadata?: ExtensionInstallMetadata, +): Promise { + // Use loadOpenPluginConfig to get standard mapping + const config = await loadOpenPluginConfig( + manifestPath, + pluginDir, + process.cwd(), + ); + + return { + name: config.name, + version: config.version, + path: pluginDir, + isActive, + id, + installMetadata, + manifestType: 'open-plugin', + description: config.description, + author: config.author, + license: config.license, + repository: config.repository, + homepage: config.homepage, + logo: config.logo, + keywords: config.keywords, + // v1: Features disabled + contextFiles: [], + mcpServers: undefined, + excludeTools: undefined, + settings: undefined, + resolvedSettings: undefined, + skills: undefined, + agents: undefined, + themes: config.themes, + }; +} diff --git a/packages/cli/src/ui/components/views/ExtensionsList.tsx b/packages/cli/src/ui/components/views/ExtensionsList.tsx index 7b9c66d577..e595a7dbfa 100644 --- a/packages/cli/src/ui/components/views/ExtensionsList.tsx +++ b/packages/cli/src/ui/components/views/ExtensionsList.tsx @@ -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 = ({ extensions }) => { return ( - - {`${ext.name} (v${ext.version})`} - {` - ${activeString}`} - {{` (${stateText})`}} - + + + {`${ext.name} (v${ext.version})`} + {` - ${activeString}`} + {` (${stateText})`} + + {ext.manifestType === 'open-plugin' && ( + + + {' '} + Plugin{' '} + + + )} + + {ext.description && ( + + {ext.description} + + )} {ext.resolvedSettings && ext.resolvedSettings.length > 0 && ( settings: diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9e9133bb82..94f3945de2 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -382,6 +382,14 @@ 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; + repository?: string | { type: string; url: string; directory?: string }; + homepage?: string; + logo?: string; + keywords?: string[]; mcpServers?: Record; contextFiles: string[]; excludeTools?: string[];