diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 2f1675ff9..a7333b704 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -12,6 +12,7 @@ import { import type { ExtensionInstallMetadata } from '@qwen-code/qwen-code-core'; import { getErrorMessage } from '../../utils/errors.js'; import { stat } from 'node:fs/promises'; +import { parseMarketplaceSource } from '../../config/extensions/marketplace.js'; interface InstallArgs { source: string; @@ -23,7 +24,20 @@ export async function handleInstall(args: InstallArgs) { try { let installMetadata: ExtensionInstallMetadata; const { source } = args; - if ( + + // Check if it's a marketplace source (format: marketplace-url:plugin-name) + const marketplaceParsed = parseMarketplaceSource(source); + if (marketplaceParsed) { + if (args.ref || args.autoUpdate) { + throw new Error( + '--ref and --auto-update are not applicable for marketplace extensions.', + ); + } + installMetadata = { + source, + type: 'marketplace', + }; + } else if ( source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git@') || @@ -65,11 +79,13 @@ export async function handleInstall(args: InstallArgs) { export const installCommand: CommandModule = { command: 'install ', - describe: 'Installs an extension from a git repository URL or a local path.', + describe: + 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).', builder: (yargs) => yargs .positional('source', { - describe: 'The github URL or local path of the extension to install.', + describe: + 'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.', type: 'string', demandOption: true, }) diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index e9ed41635..8942e2e9b 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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, + 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 { + 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 { diff --git a/packages/cli/src/config/extensions/converter.test.ts b/packages/cli/src/config/extensions/converter.test.ts new file mode 100644 index 000000000..67c3cc2d1 --- /dev/null +++ b/packages/cli/src/config/extensions/converter.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/cli/src/config/extensions/gemini-converter.test.ts b/packages/cli/src/config/extensions/gemini-converter.test.ts index 94e1f11f5..b53b2ccaa 100644 --- a/packages/cli/src/config/extensions/gemini-converter.test.ts +++ b/packages/cli/src/config/extensions/gemini-converter.test.ts @@ -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(); + 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 diff --git a/packages/cli/src/config/extensions/gemini-converter.ts b/packages/cli/src/config/extensions/gemini-converter.ts index 2ed71c037..2aa0b7a74 100644 --- a/packages/cli/src/config/extensions/gemini-converter.ts +++ b/packages/cli/src/config/extensions/gemini-converter.ts @@ -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; 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 { + // 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 { + // 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; + 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; // Must have name and version if (typeof obj['name'] !== 'string' || typeof obj['version'] !== 'string') { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index de4c9189d..d76a4fd69 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -992,7 +992,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem( { type: MessageType.INFO, - text: `✅ [${level}] Successfully migrated ${migrationResult.convertedFiles.length} command file${migrationResult.convertedFiles.length > 1 ? 's' : ''} to Markdown format. Original files backed up as .toml.backup`, + text: `[${level}] Successfully migrated ${migrationResult.convertedFiles.length} command file${migrationResult.convertedFiles.length > 1 ? 's' : ''} to Markdown format. Original files backed up as .toml.backup`, }, Date.now(), ); @@ -1002,7 +1002,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem( { type: MessageType.ERROR, - text: `⚠️ [${level}] Failed to migrate ${migrationResult.failedFiles.length} file${migrationResult.failedFiles.length > 1 ? 's' : ''}:\n${migrationResult.failedFiles.map((f) => ` • ${f.file}: ${f.error}`).join('\n')}`, + text: `[${level}] Failed to migrate ${migrationResult.failedFiles.length} file${migrationResult.failedFiles.length > 1 ? 's' : ''}:\n${migrationResult.failedFiles.map((f) => ` • ${f.file}: ${f.error}`).join('\n')}`, }, Date.now(), ); @@ -1013,7 +1013,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.addItem( { type: MessageType.INFO, - text: 'ℹ️ No TOML files found to migrate.', + text: 'No TOML files found to migrate.', }, Date.now(), ); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 34dbb4649..3f5c4c63f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -202,7 +202,7 @@ export interface GeminiCLIExtension { export interface ExtensionInstallMetadata { source: string; - type: 'git' | 'local' | 'link' | 'github-release'; + type: 'git' | 'local' | 'link' | 'github-release' | 'marketplace'; releaseTag?: string; // Only present for github-release installs. ref?: string; autoUpdate?: boolean; diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 3aae504ad..7a693add3 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -314,6 +314,45 @@ export class SkillManager { return skills; } + /** + * Lists skills from directory. + * + * @param dir - provided directory + * @returns Array of skill configurations + */ + async parseSkillsFromDir(dir: string): Promise { + const discoveredSkills: SkillConfig[] = []; + + try { + const absoluteSearchPath = path.resolve(dir); + const stats = await fs.stat(absoluteSearchPath).catch(() => null); + if (!stats || !stats.isDirectory()) { + return []; + } + + const skillFiles = await glob('*/SKILL.md', { + cwd: absoluteSearchPath, + absolute: true, + nodir: true, + }); + + for (const skillFile of skillFiles) { + const metadata = await this.parseSkillFile(skillFile, 'extension'); + if (metadata) { + discoveredSkills.push(metadata); + } + } + } catch (error) { + coreEvents.emitFeedback( + 'warning', + `Error discovering skills in ${dir}:`, + error, + ); + } + + return discoveredSkills; + } + /** * Parses a SKILL.md file and returns the configuration. *