mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-22 15:51:18 -07:00
feat(cli): support Open Plugin (plugin.json) manifest standard
Fixes https://github.com/google-gemini/maintainers-gemini-cli/issues/1597
This commit is contained in:
@@ -11,7 +11,19 @@ import chalk from 'chalk';
|
|||||||
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
|
||||||
import { type MergedSettings, SettingScope } from './settings.js';
|
import { type MergedSettings, SettingScope } from './settings.js';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
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 {
|
import {
|
||||||
isWorkspaceTrusted,
|
isWorkspaceTrusted,
|
||||||
loadTrustedFolders,
|
loadTrustedFolders,
|
||||||
@@ -65,7 +77,6 @@ import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
|||||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||||
import { ExtensionStorage } from './extensions/storage.js';
|
import { ExtensionStorage } from './extensions/storage.js';
|
||||||
import {
|
import {
|
||||||
EXTENSIONS_CONFIG_FILENAME,
|
|
||||||
INSTALL_METADATA_FILENAME,
|
INSTALL_METADATA_FILENAME,
|
||||||
recursivelyHydrateStrings,
|
recursivelyHydrateStrings,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
@@ -293,6 +304,10 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
try {
|
try {
|
||||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||||
|
|
||||||
|
if (!newExtensionConfig) {
|
||||||
|
throw new Error('Failed to load extension configuration');
|
||||||
|
}
|
||||||
|
|
||||||
const newExtensionName = newExtensionConfig.name;
|
const newExtensionName = newExtensionConfig.name;
|
||||||
const previousName = previousExtensionConfig?.name ?? newExtensionName;
|
const previousName = previousExtensionConfig?.name ?? newExtensionName;
|
||||||
const previous = this.getExtensions().find(
|
const previous = this.getExtensions().find(
|
||||||
@@ -757,23 +772,58 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
effectiveExtensionPath = installMetadata.source;
|
effectiveExtensionPath = installMetadata.source;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const manifestInfo = findManifest(effectiveExtensionPath);
|
||||||
let config = await this.loadExtensionConfig(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<string, string> = {};
|
let userSettings: Record<string, string> = {};
|
||||||
let workspaceSettings: Record<string, string> = {};
|
let workspaceSettings: Record<string, string> = {};
|
||||||
|
|
||||||
if (this.settings.experimental.extensionConfig) {
|
if (this.settings.experimental.extensionConfig) {
|
||||||
userSettings = await getScopedEnvContents(
|
userSettings = await getScopedEnvContents(
|
||||||
config,
|
rawConfig,
|
||||||
extensionId,
|
extensionId,
|
||||||
ExtensionSettingScope.USER,
|
ExtensionSettingScope.USER,
|
||||||
);
|
);
|
||||||
if (isWorkspaceTrusted(this.settings).isTrusted) {
|
if (isWorkspaceTrusted(this.settings).isTrusted) {
|
||||||
workspaceSettings = await getScopedEnvContents(
|
workspaceSettings = await getScopedEnvContents(
|
||||||
config,
|
rawConfig,
|
||||||
extensionId,
|
extensionId,
|
||||||
ExtensionSettingScope.WORKSPACE,
|
ExtensionSettingScope.WORKSPACE,
|
||||||
this.workspaceDir,
|
this.workspaceDir,
|
||||||
@@ -782,7 +832,8 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customEnv = { ...userSettings, ...workspaceSettings };
|
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[] = [];
|
const resolvedSettings: ResolvedExtensionSetting[] = [];
|
||||||
if (config.settings && this.settings.experimental.extensionConfig) {
|
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 = {
|
const hydrationContext: VariableContext = {
|
||||||
extensionPath: effectiveExtensionPath,
|
extensionPath: effectiveExtensionPath,
|
||||||
|
PLUGIN_ROOT: effectiveExtensionPath,
|
||||||
workspacePath: this.workspaceDir,
|
workspacePath: this.workspaceDir,
|
||||||
'/': path.sep,
|
'/': path.sep,
|
||||||
pathSeparator: path.sep,
|
pathSeparator: path.sep,
|
||||||
@@ -957,30 +1009,23 @@ Would you like to attempt to install via "git clone" instead?`,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return createGeminiExtension(
|
||||||
name: config.name,
|
config,
|
||||||
version: config.version,
|
effectiveExtensionPath,
|
||||||
path: effectiveExtensionPath,
|
this.extensionEnablementManager.isEnabled(
|
||||||
contextFiles,
|
|
||||||
installMetadata,
|
|
||||||
migratedTo: config.migratedTo,
|
|
||||||
mcpServers: config.mcpServers,
|
|
||||||
excludeTools: config.excludeTools,
|
|
||||||
hooks,
|
|
||||||
isActive: this.extensionEnablementManager.isEnabled(
|
|
||||||
config.name,
|
config.name,
|
||||||
this.workspaceDir,
|
this.workspaceDir,
|
||||||
),
|
),
|
||||||
id: getExtensionId(config, installMetadata),
|
extensionId,
|
||||||
settings: config.settings,
|
contextFiles,
|
||||||
resolvedSettings,
|
resolvedSettings,
|
||||||
|
installMetadata,
|
||||||
|
hooks,
|
||||||
skills,
|
skills,
|
||||||
agents: agentLoadResult.agents,
|
agentLoadResult.agents,
|
||||||
themes: config.themes,
|
|
||||||
rules,
|
rules,
|
||||||
checkers,
|
checkers,
|
||||||
plan: config.plan,
|
);
|
||||||
};
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugLogger.error(
|
debugLogger.error(
|
||||||
`Warning: Skipping extension in ${effectiveExtensionPath}: ${getErrorMessage(
|
`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<ExtensionConfig> {
|
async loadExtensionConfig(extensionDir: string): Promise<ExtensionConfig> {
|
||||||
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
|
const manifestInfo = findManifest(extensionDir);
|
||||||
if (!fs.existsSync(configFilePath)) {
|
if (!manifestInfo) {
|
||||||
throw new Error(`Configuration file not found at ${configFilePath}`);
|
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);
|
validateName(config.name);
|
||||||
return config;
|
return config;
|
||||||
} catch (e) {
|
} else {
|
||||||
throw new Error(
|
const config = await loadGeminiConfig(
|
||||||
`Failed to load extension config from ${configFilePath}: ${getErrorMessage(
|
manifestInfo.path,
|
||||||
e,
|
extensionDir,
|
||||||
)}`,
|
this.workspaceDir,
|
||||||
);
|
);
|
||||||
|
validateName(config.name);
|
||||||
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1279,9 +1312,9 @@ function getContextFileNames(config: ExtensionConfig): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateName(name: 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(
|
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.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1326,7 +1359,7 @@ export async function inferInstallMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getExtensionId(
|
export function getExtensionId(
|
||||||
config: ExtensionConfig,
|
config: ExtensionConfig | OpenPluginConfig,
|
||||||
installMetadata?: ExtensionInstallMetadata,
|
installMetadata?: ExtensionInstallMetadata,
|
||||||
): string {
|
): string {
|
||||||
// IDs are created by hashing details of the installation source in order to
|
// IDs are created by hashing details of the installation source in order to
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
INSTALL_METADATA_FILENAME,
|
INSTALL_METADATA_FILENAME,
|
||||||
} from './extensions/variables.js';
|
} from './extensions/variables.js';
|
||||||
import { hashValue, ExtensionManager } from './extension-manager.js';
|
import { hashValue, ExtensionManager } from './extension-manager.js';
|
||||||
|
import { loadGeminiConfig, createGeminiExtension } from './extension.js';
|
||||||
import { ExtensionStorage } from './extensions/storage.js';
|
import { ExtensionStorage } from './extensions/storage.js';
|
||||||
import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js';
|
import { INSTALL_WARNING_MESSAGE } from './extensions/consent.js';
|
||||||
import type { ExtensionSetting } from './extensions/extensionSettings.js';
|
import type { ExtensionSetting } from './extensions/extensionSettings.js';
|
||||||
@@ -685,9 +686,7 @@ name = "yolo-checker"
|
|||||||
expect(extensions).toHaveLength(1);
|
expect(extensions).toHaveLength(1);
|
||||||
expect(extensions[0].name).toBe('good-ext');
|
expect(extensions[0].name).toBe('good-ext');
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining(
|
expect.stringContaining(`Warning: Skipping extension in ${badExtDir}:`),
|
||||||
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
consoleSpy.mockRestore();
|
||||||
@@ -717,7 +716,7 @@ name = "yolo-checker"
|
|||||||
expect(extensions[0].name).toBe('good-ext');
|
expect(extensions[0].name).toBe('good-ext');
|
||||||
expect(consoleSpy).toHaveBeenCalledWith(
|
expect(consoleSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining(
|
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 () => {
|
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
||||||
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
|
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
|
||||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
extensionManager.installOrUpdateExtension({
|
extensionManager.installOrUpdateExtension({
|
||||||
source: sourceExtDir,
|
source: sourceExtDir,
|
||||||
type: 'local',
|
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');
|
const targetExtDir = path.join(userExtensionsDir, 'bad-extension');
|
||||||
expect(fs.existsSync(targetExtDir)).toBe(false);
|
expect(fs.existsSync(targetExtDir)).toBe(false);
|
||||||
@@ -1219,7 +1217,7 @@ name = "yolo-checker"
|
|||||||
source: sourceExtDir,
|
source: sourceExtDir,
|
||||||
type: 'local',
|
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 () => {
|
it('should throw an error for missing name in gemini-extension.json', async () => {
|
||||||
@@ -1239,9 +1237,7 @@ name = "yolo-checker"
|
|||||||
source: sourceExtDir,
|
source: sourceExtDir,
|
||||||
type: 'local',
|
type: 'local',
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow('Invalid gemini-extension.json:');
|
||||||
`Invalid configuration in ${configPath}: missing "name"`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should install an extension from a git URL', async () => {
|
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();
|
const manager = new ExtensionEnablementManager();
|
||||||
return manager.isEnabled(options.name, options.enabledForPath);
|
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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,15 +4,24 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import {
|
||||||
MCPServerConfig,
|
type MCPServerConfig,
|
||||||
ExtensionInstallMetadata,
|
type ExtensionInstallMetadata,
|
||||||
CustomTheme,
|
type CustomTheme,
|
||||||
|
type PolicyRule,
|
||||||
|
type SafetyCheckerRule,
|
||||||
|
type GeminiCLIExtension,
|
||||||
|
type ResolvedExtensionSetting,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as path from 'node:path';
|
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 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.
|
* 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 {
|
export interface ExtensionConfig {
|
||||||
name: string;
|
name: string;
|
||||||
version: 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<string, MCPServerConfig>;
|
mcpServers?: Record<string, MCPServerConfig>;
|
||||||
contextFileName?: string | string[];
|
contextFileName?: string | string[];
|
||||||
excludeTools?: string[];
|
excludeTools?: string[];
|
||||||
@@ -48,6 +65,47 @@ export interface ExtensionConfig {
|
|||||||
migratedTo?: string;
|
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 {
|
export interface ExtensionUpdateInfo {
|
||||||
name: string;
|
name: string;
|
||||||
originalVersion: string;
|
originalVersion: string;
|
||||||
@@ -67,3 +125,87 @@ export function loadInstallMetadata(
|
|||||||
return undefined;
|
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,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const VARIABLE_SCHEMA = {
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The path of the extension in the filesystem.',
|
description: 'The path of the extension in the filesystem.',
|
||||||
},
|
},
|
||||||
|
PLUGIN_ROOT: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The root path of the plugin (alias for extensionPath).',
|
||||||
|
},
|
||||||
workspacePath: {
|
workspacePath: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'The absolute path of the current workspace.',
|
description: 'The absolute path of the current workspace.',
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ const UNMARSHALL_KEY_IGNORE_LIST: Set<string> = new Set<string>([
|
|||||||
|
|
||||||
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
|
||||||
export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
|
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 INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
|
||||||
export const EXTENSION_SETTINGS_FILENAME = '.env';
|
export const EXTENSION_SETTINGS_FILENAME = '.env';
|
||||||
|
|
||||||
|
|||||||
@@ -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<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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>;
|
||||||
|
agents?: string[] | Record<string, unknown>;
|
||||||
|
hooks?: string[] | Record<string, unknown>;
|
||||||
|
mcpServers?: string[] | Record<string, unknown>;
|
||||||
|
lspServers?: string[] | Record<string, unknown>;
|
||||||
|
rules?: string[] | Record<string, unknown>;
|
||||||
|
// 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<ExtensionConfig> {
|
||||||
|
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<GeminiCLIExtension> {
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { useUIState } from '../../contexts/UIStateContext.js';
|
|||||||
import { ExtensionUpdateState } from '../../state/extensions.js';
|
import { ExtensionUpdateState } from '../../state/extensions.js';
|
||||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
||||||
import { getFormattedSettingValue } from '../../../commands/extensions/utils.js';
|
import { getFormattedSettingValue } from '../../../commands/extensions/utils.js';
|
||||||
|
import { theme } from '../../semantic-colors.js';
|
||||||
|
|
||||||
interface ExtensionsList {
|
interface ExtensionsList {
|
||||||
extensions: readonly GeminiCLIExtension[];
|
extensions: readonly GeminiCLIExtension[];
|
||||||
@@ -61,11 +62,29 @@ export const ExtensionsList: React.FC<ExtensionsList> = ({ extensions }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box key={ext.name} flexDirection="column" marginBottom={1}>
|
<Box key={ext.name} flexDirection="column" marginBottom={1}>
|
||||||
<Text>
|
<Box flexDirection="row">
|
||||||
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
|
<Text>
|
||||||
<Text color={activeColor}>{` - ${activeString}`}</Text>
|
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
|
||||||
{<Text color={stateColor}>{` (${stateText})`}</Text>}
|
<Text color={activeColor}>{` - ${activeString}`}</Text>
|
||||||
</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 && (
|
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
|
||||||
<Box flexDirection="column" paddingLeft={2}>
|
<Box flexDirection="column" paddingLeft={2}>
|
||||||
<Text>settings:</Text>
|
<Text>settings:</Text>
|
||||||
|
|||||||
@@ -348,6 +348,14 @@ export interface GeminiCLIExtension {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
path: string;
|
path: string;
|
||||||
installMetadata?: ExtensionInstallMetadata;
|
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<string, MCPServerConfig>;
|
mcpServers?: Record<string, MCPServerConfig>;
|
||||||
contextFiles: string[];
|
contextFiles: string[];
|
||||||
excludeTools?: string[];
|
excludeTools?: string[];
|
||||||
|
|||||||
Reference in New Issue
Block a user