feat: install from gemini

This commit is contained in:
LaZzyMan 2026-01-07 19:17:34 +08:00
parent 50dac93c80
commit 18713ef2b0
8 changed files with 681 additions and 41 deletions

View file

@ -38,6 +38,16 @@ import type { LoadExtensionContext } from './extensions/variableSchema.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
import chalk from 'chalk';
import type { ConfirmationRequest } from '../ui/types.js';
import {
installFromMarketplace,
parseMarketplaceSource,
} from './extensions/marketplace.js';
import { isClaudePluginConfig } from './extensions/claude-converter.js';
import {
isGeminiExtensionConfig,
convertGeminiExtensionPackage,
} from './extensions/gemini-converter.js';
import { glob } from 'glob';
export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions');
@ -493,7 +503,27 @@ export async function installExtension(
let tempDir: string | undefined;
if (
// Handle marketplace installation
if (installMetadata.type === 'marketplace') {
const marketplaceParsed = parseMarketplaceSource(installMetadata.source);
if (!marketplaceParsed) {
throw new Error(
`Invalid marketplace source format: ${installMetadata.source}. Expected format: marketplace-url:plugin-name`,
);
}
tempDir = await ExtensionStorage.createTmpDir();
const marketplaceResult = await installFromMarketplace({
marketplaceUrl: marketplaceParsed.marketplaceUrl,
pluginName: marketplaceParsed.pluginName,
tempDir,
requestConsent,
});
newExtensionConfig = marketplaceResult.config;
localSourcePath = marketplaceResult.sourcePath;
installMetadata = marketplaceResult.installMetadata;
} else if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
@ -520,10 +550,14 @@ export async function installExtension(
}
try {
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
});
localSourcePath = await convertGeminiOrClaudeExtension(localSourcePath);
// Load extension config if not already loaded (from marketplace)
if (!newExtensionConfig) {
newExtensionConfig = loadExtensionConfig({
extensionDir: localSourcePath,
workspaceDir: cwd,
});
}
const newExtensionName = newExtensionConfig.name;
const extensionStorage = new ExtensionStorage(newExtensionName);
@ -539,9 +573,16 @@ export async function installExtension(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
}
const commands = await loadCommandsFromDir(
`${localSourcePath}/commands`,
newExtensionConfig.name,
);
await maybeRequestConsentOrFail(
newExtensionConfig,
requestConsent,
commands,
previousExtensionConfig,
);
await fs.promises.mkdir(destinationPath, { recursive: true });
@ -564,6 +605,9 @@ export async function installExtension(
if (tempDir) {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
if (localSourcePath !== tempDir) {
await fs.promises.rm(localSourcePath, { recursive: true, force: true });
}
}
logExtensionInstallEvent(
@ -608,7 +652,10 @@ export async function installExtension(
* Builds a consent string for installing an extension based on it's
* extensionConfig.
*/
function extensionConsentString(extensionConfig: ExtensionConfig): string {
function extensionConsentString(
extensionConfig: ExtensionConfig,
commands?: string[],
): string {
const output: string[] = [];
const mcpServerEntries = Object.entries(extensionConfig.mcpServers || {});
output.push(`Installing extension "${extensionConfig.name}".`);
@ -626,6 +673,11 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
}
}
if (commands && commands.length > 0) {
output.push(
`This extension will add the following commands: ${commands.join(', ')}.`,
);
}
if (extensionConfig.contextFileName) {
output.push(
`This extension will append info to your QWEN.md context using ${extensionConfig.contextFileName}`,
@ -651,9 +703,10 @@ function extensionConsentString(extensionConfig: ExtensionConfig): string {
async function maybeRequestConsentOrFail(
extensionConfig: ExtensionConfig,
requestConsent: (consent: string) => Promise<boolean>,
commands: string[],
previousExtensionConfig?: ExtensionConfig,
) {
const extensionConsent = extensionConsentString(extensionConfig);
const extensionConsent = extensionConsentString(extensionConfig, commands);
if (previousExtensionConfig) {
const previousExtensionConsent = extensionConsentString(
previousExtensionConfig,
@ -667,6 +720,49 @@ async function maybeRequestConsentOrFail(
}
}
async function loadCommandsFromDir(
dir: string,
extensionName: string,
): Promise<string[]> {
const globOptions = {
nodir: true,
dot: true,
follow: true,
};
try {
const mdFiles = await glob('**/*.md', {
...globOptions,
cwd: dir,
});
const commandNames = mdFiles.map((file) => {
const relativePathWithExt = path.relative(dir, path.join(dir, file));
const relativePath = relativePathWithExt.substring(
0,
relativePathWithExt.length - 3,
);
const baseCommandName = relativePath
.split(path.sep)
.map((segment) => segment.replaceAll(':', '_'))
.join(':');
const commandName = `${extensionName}:${baseCommandName}`;
return commandName;
});
return commandNames;
} catch (error) {
// Ignore ENOENT (directory doesn't exist) and AbortError (operation was cancelled)
const isEnoent = (error as NodeJS.ErrnoException).code === 'ENOENT';
const isAbortError = error instanceof Error && error.name === 'AbortError';
if (!isEnoent && !isAbortError) {
console.error(`Error loading commands from ${dir}:`, error);
}
return [];
}
}
export function validateName(name: string) {
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
throw new Error(
@ -675,6 +771,20 @@ export function validateName(name: string) {
}
}
async function convertGeminiOrClaudeExtension(extensionDir: string) {
let newExtensionDir = extensionDir;
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
if (fs.existsSync(configFilePath)) {
newExtensionDir = extensionDir;
} else if (isGeminiExtensionConfig(extensionDir)) {
newExtensionDir = (await convertGeminiExtensionPackage(extensionDir))
.convertedDir;
} else if (isClaudePluginConfig(extensionDir)) {
// claude
}
return newExtensionDir;
}
export function loadExtensionConfig(
context: LoadExtensionContext,
): ExtensionConfig {

View file

@ -0,0 +1,228 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
isClaudePluginConfig,
convertClaudeToQwenConfig,
mergeClaudeConfigs,
type ClaudePluginConfig,
type ClaudeMarketplacePluginConfig,
} from './claude-converter.js';
import {
isGeminiExtensionConfig,
convertGeminiToQwenConfig,
type GeminiExtensionConfig,
} from './gemini-converter.js';
import { parseMarketplaceSource } from './marketplace.js';
describe('Claude Converter', () => {
describe('isClaudePluginConfig', () => {
it('should detect Claude plugin config', () => {
const config = {
name: 'test-plugin',
version: '1.0.0',
agents: 'agents/',
hooks: 'hooks.js',
};
expect(isClaudePluginConfig(config)).toBe(true);
});
it('should return false for non-Claude config', () => {
const config = {
name: 'test-plugin',
version: '1.0.0',
commands: 'commands/',
};
expect(isClaudePluginConfig(config)).toBe(false);
});
it('should return false for invalid config', () => {
expect(isClaudePluginConfig(null)).toBe(false);
expect(isClaudePluginConfig('string')).toBe(false);
expect(isClaudePluginConfig({})).toBe(false);
});
});
describe('convertClaudeToQwenConfig', () => {
it('should convert basic Claude config', () => {
const claudeConfig: ClaudePluginConfig = {
name: 'claude-plugin',
version: '1.0.0',
commands: 'commands/',
agents: 'agents/',
};
const qwenConfig = convertClaudeToQwenConfig(claudeConfig);
expect(qwenConfig.name).toBe('claude-plugin');
expect(qwenConfig.version).toBe('1.0.0');
expect(qwenConfig.commands).toBe('commands/');
expect(qwenConfig.agents).toBe('agents/');
});
it('should throw error for invalid config', () => {
expect(() => convertClaudeToQwenConfig({} as ClaudePluginConfig)).toThrow(
'Claude plugin config must have name and version fields',
);
});
});
describe('mergeClaudeConfigs', () => {
it('should merge marketplace and plugin configs', () => {
const marketplaceConfig: ClaudeMarketplacePluginConfig = {
name: 'plugin',
version: '2.0.0',
source: 'https://github.com/example/plugin',
description: 'Updated description',
};
const pluginConfig: ClaudePluginConfig = {
name: 'plugin',
version: '1.0.0',
description: 'Original description',
commands: 'commands/',
};
const merged = mergeClaudeConfigs(marketplaceConfig, pluginConfig);
expect(merged.name).toBe('plugin');
expect(merged.version).toBe('2.0.0');
expect(merged.description).toBe('Updated description');
expect(merged.commands).toBe('commands/');
});
it('should throw error in strict mode without plugin config', () => {
const marketplaceConfig: ClaudeMarketplacePluginConfig = {
name: 'plugin',
version: '1.0.0',
source: 'https://github.com/example/plugin',
strict: true,
};
expect(() => mergeClaudeConfigs(marketplaceConfig)).toThrow(
'Plugin plugin requires plugin.json (strict mode)',
);
});
});
});
describe('Gemini Converter', () => {
describe('isGeminiExtensionConfig', () => {
it('should detect Gemini extension config with settings', () => {
const config = {
name: 'test-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key',
envVar: 'MY_API_KEY',
},
],
};
expect(isGeminiExtensionConfig(config)).toBe(true);
});
it('should return true for basic Gemini config without settings', () => {
const config = {
name: 'test-extension',
version: '1.0.0',
contextFileName: 'QWEN.md',
};
expect(isGeminiExtensionConfig(config)).toBe(true);
});
it('should return false for invalid config', () => {
expect(isGeminiExtensionConfig(null)).toBe(false);
expect(isGeminiExtensionConfig('string')).toBe(false);
expect(isGeminiExtensionConfig({})).toBe(false);
});
});
describe('convertGeminiToQwenConfig', () => {
it('should convert basic Gemini config', () => {
const geminiConfig: GeminiExtensionConfig = {
name: 'gemini-extension',
version: '1.0.0',
commands: 'commands/',
excludeTools: ['tool1', 'tool2'],
};
const qwenConfig = convertGeminiToQwenConfig(geminiConfig);
expect(qwenConfig.name).toBe('gemini-extension');
expect(qwenConfig.version).toBe('1.0.0');
expect(qwenConfig.commands).toBe('commands/');
expect(qwenConfig.excludeTools).toEqual(['tool1', 'tool2']);
});
it('should convert config with settings', () => {
const geminiConfig: GeminiExtensionConfig = {
name: 'gemini-extension',
version: '1.0.0',
settings: [
{
name: 'API Key',
description: 'Your API key',
envVar: 'MY_API_KEY',
sensitive: true,
},
],
};
const qwenConfig = convertGeminiToQwenConfig(geminiConfig);
expect(qwenConfig.settings).toEqual(geminiConfig.settings);
});
it('should throw error for invalid config', () => {
expect(() =>
convertGeminiToQwenConfig({} as GeminiExtensionConfig),
).toThrow('Gemini extension config must have name and version fields');
});
});
});
describe('Marketplace Parser', () => {
describe('parseMarketplaceSource', () => {
it('should parse valid marketplace source', () => {
const result = parseMarketplaceSource(
'https://github.com/example/marketplace:my-plugin',
);
expect(result).toEqual({
marketplaceUrl: 'https://github.com/example/marketplace',
pluginName: 'my-plugin',
});
});
it('should parse HTTP marketplace source', () => {
const result = parseMarketplaceSource(
'http://example.com/marketplace:plugin-name',
);
expect(result).toEqual({
marketplaceUrl: 'http://example.com/marketplace',
pluginName: 'plugin-name',
});
});
it('should handle multiple colons in URL', () => {
const result = parseMarketplaceSource(
'https://github.com:8080/repo:plugin',
);
expect(result).toEqual({
marketplaceUrl: 'https://github.com:8080/repo',
pluginName: 'plugin',
});
});
it('should return null for invalid sources', () => {
expect(parseMarketplaceSource('not-a-url:plugin')).toBeNull();
expect(parseMarketplaceSource('https://github.com/repo')).toBeNull();
expect(parseMarketplaceSource('https://github.com/repo:')).toBeNull();
});
});
});

View file

@ -4,60 +4,176 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
convertGeminiToQwenConfig,
isGeminiExtensionConfig,
type GeminiExtensionConfig,
} from './gemini-converter.js';
// Mock fs module
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return {
...actual,
existsSync: vi.fn(),
readFileSync: vi.fn(),
};
});
describe('convertGeminiToQwenConfig', () => {
it('should convert basic Gemini config', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should convert basic Gemini config from directory', () => {
const mockDir = '/mock/extension/dir';
const geminiConfig: GeminiExtensionConfig = {
name: 'test-extension',
version: '1.0.0',
};
const result = convertGeminiToQwenConfig(geminiConfig);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(geminiConfig));
const result = convertGeminiToQwenConfig(mockDir);
expect(result.name).toBe('test-extension');
expect(result.version).toBe('1.0.0');
expect(fs.readFileSync).toHaveBeenCalledWith(
path.join(mockDir, 'gemini-extension.json'),
'utf-8',
);
});
it('should convert config with commands', () => {
const geminiConfig: GeminiExtensionConfig = {
name: 'cmd-extension',
version: '1.0.0',
commands: 'commands',
it('should convert config with all optional fields', () => {
const mockDir = '/mock/extension/dir';
const geminiConfig = {
name: 'full-extension',
version: '2.0.0',
mcpServers: { server1: {} },
contextFileName: 'context.txt',
excludeTools: ['tool1', 'tool2'],
settings: [
{ name: 'Setting1', envVar: 'VAR1', description: 'Test setting' },
],
};
const result = convertGeminiToQwenConfig(geminiConfig);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(geminiConfig));
expect(result.commands).toBe('commands');
const result = convertGeminiToQwenConfig(mockDir);
expect(result.name).toBe('full-extension');
expect(result.version).toBe('2.0.0');
expect(result.mcpServers).toEqual({ server1: {} });
expect(result.contextFileName).toBe('context.txt');
expect(result.excludeTools).toEqual(['tool1', 'tool2']);
expect(result.settings).toHaveLength(1);
expect(result.settings?.[0].name).toBe('Setting1');
});
it('should throw error for missing name', () => {
const mockDir = '/mock/extension/dir';
const invalidConfig = {
version: '1.0.0',
} as GeminiExtensionConfig;
};
expect(() => convertGeminiToQwenConfig(invalidConfig)).toThrow();
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
expect(() => convertGeminiToQwenConfig(mockDir)).toThrow(
'Gemini extension config must have name and version fields',
);
});
it('should throw error for missing version', () => {
const mockDir = '/mock/extension/dir';
const invalidConfig = {
name: 'test-extension',
};
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
expect(() => convertGeminiToQwenConfig(mockDir)).toThrow(
'Gemini extension config must have name and version fields',
);
});
});
describe('isGeminiExtensionConfig', () => {
it('should identify Gemini config with settings', () => {
const config = {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should identify Gemini extension directory with valid config', () => {
const mockDir = '/mock/extension/dir';
const mockConfig = {
name: 'test',
version: '1.0.0',
settings: [{ name: 'Test', envVar: 'TEST', description: 'Test' }],
};
expect(isGeminiExtensionConfig(config)).toBe(true);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockConfig));
expect(isGeminiExtensionConfig(mockDir)).toBe(true);
expect(fs.existsSync).toHaveBeenCalledWith(
path.join(mockDir, 'gemini-extension.json'),
);
});
it('should return false for invalid config', () => {
expect(isGeminiExtensionConfig(null)).toBe(false);
expect(isGeminiExtensionConfig({})).toBe(false);
it('should return false when gemini-extension.json does not exist', () => {
const mockDir = '/mock/nonexistent/dir';
vi.mocked(fs.existsSync).mockReturnValue(false);
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
});
it('should return false for invalid config content', () => {
const mockDir = '/mock/invalid/dir';
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('null');
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
});
it('should return false for config missing required fields', () => {
const mockDir = '/mock/invalid/dir';
const invalidConfig = {
name: 'test',
// missing version
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(invalidConfig));
expect(isGeminiExtensionConfig(mockDir)).toBe(false);
});
it('should return true for basic config without settings', () => {
const mockDir = '/mock/extension/dir';
const basicConfig = {
name: 'test',
version: '1.0.0',
};
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(basicConfig));
expect(isGeminiExtensionConfig(mockDir)).toBe(true);
});
});
// Note: convertGeminiExtensionPackage() is tested through integration tests
// as it requires real file system operations

View file

@ -8,7 +8,12 @@
* Converter for Gemini CLI extensions to Qwen Code format.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { glob } from 'glob';
import type { ExtensionConfig, ExtensionSetting } from '../extension.js';
import { ExtensionStorage } from '../extension.js';
import { convertTomlToMarkdown } from '../../services/toml-to-markdown-converter.js';
export interface GeminiExtensionConfig {
name: string;
@ -16,18 +21,20 @@ export interface GeminiExtensionConfig {
mcpServers?: Record<string, unknown>;
contextFileName?: string | string[];
excludeTools?: string[];
commands?: string | string[];
settings?: ExtensionSetting[];
}
/**
* Converts a Gemini CLI extension config to Qwen Code format.
* @param geminiConfig Gemini extension configuration
* @param extensionDir Path to the Gemini extension directory
* @returns Qwen ExtensionConfig
*/
export function convertGeminiToQwenConfig(
geminiConfig: GeminiExtensionConfig,
extensionDir: string,
): ExtensionConfig {
const configFilePath = path.join(extensionDir, 'gemini-extension.json');
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const geminiConfig: GeminiExtensionConfig = JSON.parse(configContent);
// Validate required fields
if (!geminiConfig.name || !geminiConfig.version) {
throw new Error(
@ -44,25 +51,149 @@ export function convertGeminiToQwenConfig(
mcpServers: geminiConfig.mcpServers as ExtensionConfig['mcpServers'],
contextFileName: geminiConfig.contextFileName,
excludeTools: geminiConfig.excludeTools,
commands: geminiConfig.commands,
settings,
};
}
/**
* Converts a complete Gemini extension package to Qwen Code format.
* Creates a new temporary directory with:
* 1. Converted qwen-extension.json
* 2. Commands converted from TOML to MD
* 3. All other files/folders preserved
*
* @param extensionDir Path to the Gemini extension directory
* @returns Object containing converted config and the temporary directory path
*/
export async function convertGeminiExtensionPackage(
extensionDir: string,
): Promise<{ config: ExtensionConfig; convertedDir: string }> {
const geminiConfig = convertGeminiToQwenConfig(extensionDir);
// Create temporary directory for converted extension
const tmpDir = await ExtensionStorage.createTmpDir();
try {
// Step 1: Copy all files and directories to temporary directory
await copyDirectory(extensionDir, tmpDir);
// Step 2: Convert TOML commands to Markdown in commands folder
const commandsDir = path.join(tmpDir, 'commands');
if (fs.existsSync(commandsDir)) {
await convertCommandsDirectory(commandsDir);
}
// Step 3: Create qwen-extension.json with converted config
const qwenConfigPath = path.join(tmpDir, 'qwen-extension.json');
fs.writeFileSync(
qwenConfigPath,
JSON.stringify(geminiConfig, null, 2),
'utf-8',
);
return {
config: geminiConfig,
convertedDir: tmpDir,
};
} catch (error) {
// Clean up temporary directory on error
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
throw error;
}
}
/**
* Recursively copies a directory and its contents.
* @param source Source directory path
* @param destination Destination directory path
*/
async function copyDirectory(
source: string,
destination: string,
): Promise<void> {
// Create destination directory if it doesn't exist
if (!fs.existsSync(destination)) {
fs.mkdirSync(destination, { recursive: true });
}
const entries = fs.readdirSync(source, { withFileTypes: true });
for (const entry of entries) {
const sourcePath = path.join(source, entry.name);
const destPath = path.join(destination, entry.name);
if (entry.isDirectory()) {
await copyDirectory(sourcePath, destPath);
} else {
fs.copyFileSync(sourcePath, destPath);
}
}
}
/**
* Converts all TOML command files in a directory to Markdown format.
* @param commandsDir Path to the commands directory
*/
async function convertCommandsDirectory(commandsDir: string): Promise<void> {
// Find all .toml files in the commands directory
const tomlFiles = await glob('**/*.toml', {
cwd: commandsDir,
nodir: true,
dot: false,
});
// Convert each TOML file to Markdown
for (const relativeFile of tomlFiles) {
const tomlPath = path.join(commandsDir, relativeFile);
try {
// Read TOML file
const tomlContent = fs.readFileSync(tomlPath, 'utf-8');
// Convert to Markdown
const markdownContent = convertTomlToMarkdown(tomlContent);
// Generate Markdown file path (same location, .md extension)
const markdownPath = tomlPath.replace(/\.toml$/, '.md');
// Write Markdown file
fs.writeFileSync(markdownPath, markdownContent, 'utf-8');
// Delete original TOML file
fs.unlinkSync(tomlPath);
} catch (error) {
console.warn(
`Warning: Failed to convert command file ${relativeFile}: ${error instanceof Error ? error.message : String(error)}`,
);
// Continue with other files even if one fails
}
}
}
/**
* Checks if a config object is in Gemini format.
* This is a heuristic check based on typical Gemini extension patterns.
* @param config Configuration object to check
* @returns true if config appears to be Gemini format
*/
export function isGeminiExtensionConfig(
config: unknown,
): config is GeminiExtensionConfig {
if (typeof config !== 'object' || config === null) {
export function isGeminiExtensionConfig(extensionDir: string) {
const configFilePath = path.join(extensionDir, 'gemini-extension.json');
if (!fs.existsSync(configFilePath)) {
return false;
}
const obj = config as Record<string, unknown>;
const configContent = fs.readFileSync(configFilePath, 'utf-8');
const parsedConfig = JSON.parse(configContent);
if (typeof parsedConfig !== 'object' || parsedConfig === null) {
return false;
}
const obj = parsedConfig as Record<string, unknown>;
// Must have name and version
if (typeof obj['name'] !== 'string' || typeof obj['version'] !== 'string') {