mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-30 20:50:34 +00:00
feat: install from gemini
This commit is contained in:
parent
50dac93c80
commit
18713ef2b0
8 changed files with 681 additions and 41 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
228
packages/cli/src/config/extensions/converter.test.ts
Normal file
228
packages/cli/src/config/extensions/converter.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue