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:
ruomeng
2026-04-03 16:56:43 -04:00
parent a565774122
commit 5cb7419fc2
6 changed files with 158 additions and 74 deletions
@@ -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)}`;
+25 -36
View File
@@ -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');
});
});
+50 -22
View File
@@ -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,
+3
View File
@@ -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[];