diff --git a/packages/cli/src/commands/extensions/utils.test.ts b/packages/cli/src/commands/extensions/utils.test.ts index 2168d2d7c..278ee7a54 100644 --- a/packages/cli/src/commands/extensions/utils.test.ts +++ b/packages/cli/src/commands/extensions/utils.test.ts @@ -5,7 +5,8 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getExtensionManager } from './utils.js'; +import { getExtensionManager, extensionToOutputString } from './utils.js'; +import type { Extension, ExtensionManager } from '@qwen-code/qwen-code-core'; const mockRefreshCache = vi.fn(); const mockExtensionManagerInstance = { @@ -64,3 +65,70 @@ describe('getExtensionManager', () => { ); }); }); + +describe('extensionToOutputString', () => { + const mockIsEnabled = vi.fn(); + const mockExtensionManager = { + isEnabled: mockIsEnabled, + } as unknown as ExtensionManager; + + const createMockExtension = (overrides = {}): Extension => ({ + id: 'test-ext-id', + name: 'test-extension', + version: '1.0.0', + isActive: true, + path: '/path/to/extension', + contextFiles: [], + config: { name: 'test-extension', version: '1.0.0' }, + ...overrides, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockIsEnabled.mockReturnValue(true); + }); + + it('should include status icon when inline is false', () => { + const extension = createMockExtension(); + const result = extensionToOutputString( + extension, + mockExtensionManager, + '/workspace', + false, + ); + + // Should contain either ✓ or ✗ (with ANSI color codes) + expect(result).toMatch(/test-extension/); + expect(result).toContain('(1.0.0)'); + }); + + it('should exclude status icon when inline is true', () => { + const extension = createMockExtension(); + const result = extensionToOutputString( + extension, + mockExtensionManager, + '/workspace', + true, + ); + + // Should start with extension name (after stripping potential whitespace) + expect(result.trim()).toMatch(/^test-extension/); + }); + + it('should default inline to false', () => { + const extension = createMockExtension(); + const resultWithoutInline = extensionToOutputString( + extension, + mockExtensionManager, + '/workspace', + ); + const resultWithInlineFalse = extensionToOutputString( + extension, + mockExtensionManager, + '/workspace', + false, + ); + + expect(resultWithoutInline).toEqual(resultWithInlineFalse); + }); +}); diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index b7605f32c..97e7a8d2f 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -32,6 +32,7 @@ export function extensionToOutputString( extension: Extension, extensionManager: ExtensionManager, workspaceDir: string, + inline = false, ): string { const cwd = workspaceDir; const userEnabled = extensionManager.isEnabled( @@ -44,7 +45,7 @@ export function extensionToOutputString( ); const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗'); - let output = `${status} ${extension.config.name} (${extension.config.version})`; + let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`; output += `\n Path: ${extension.path}`; if (extension.installMetadata) { output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`; diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index bad4b87f2..c14fdb389 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -777,4 +777,87 @@ describe('extensionsCommand', () => { ); }); }); + + describe('detail', () => { + const detailAction = extensionsCommand.subCommands?.find( + (cmd) => cmd.name === 'detail', + )?.action; + + if (!detailAction) { + throw new Error('Detail action not found'); + } + + let realMockExtensionManager: ExtensionManager; + + beforeEach(() => { + vi.resetAllMocks(); + realMockExtensionManager = Object.create(ExtensionManager.prototype); + realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions; + + mockContext = createMockCommandContext({ + invocation: { + raw: '/extensions detail', + name: 'detail', + args: '', + }, + services: { + config: { + getExtensions: mockGetExtensions, + getWorkingDir: () => '/test/dir', + getExtensionManager: () => realMockExtensionManager, + }, + }, + ui: { + dispatchExtensionStateUpdate: vi.fn(), + }, + }); + }); + + it('should show usage if no name is provided', async () => { + await detailAction(mockContext, ''); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Usage: /extensions detail ', + }, + expect.any(Number), + ); + }); + + it('should show error if extension not found', async () => { + mockGetExtensions.mockReturnValue([]); + await detailAction(mockContext, 'nonexistent-extension'); + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.ERROR, + text: 'Extension "nonexistent-extension" not found.', + }, + expect.any(Number), + ); + }); + + it('should show extension details when found', async () => { + const extension: Extension = { + id: 'test-ext', + name: 'test-ext', + version: '1.0.0', + isActive: true, + path: '/test/dir/test-ext', + contextFiles: [], + config: { name: 'test-ext', version: '1.0.0' }, + }; + mockGetExtensions.mockReturnValue([extension]); + realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true); + + await detailAction(mockContext, 'test-ext'); + + expect(mockContext.ui.addItem).toHaveBeenCalledWith( + { + type: MessageType.INFO, + text: expect.stringContaining('test-ext'), + }, + expect.any(Number), + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index c14365a71..e13df24f7 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -20,6 +20,7 @@ import { } from '@qwen-code/qwen-code-core'; import { SettingScope } from '../../config/settings.js'; import open from 'open'; +import { extensionToOutputString } from '../../commands/extensions/utils.js'; const EXTENSION_EXPLORE_URL = { Gemini: 'https://geminicli.com/extensions/', @@ -475,6 +476,53 @@ async function enableAction(context: CommandContext, args: string) { } } +async function detailAction(context: CommandContext, args: string) { + const extensionManager = context.services.config?.getExtensionManager(); + if (!(extensionManager instanceof ExtensionManager)) { + console.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; + } + + const name = args.trim(); + if (!name) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Usage: /extensions detail '), + }, + Date.now(), + ); + return; + } + + const extensions = context.services.config!.getExtensions(); + const extension = extensions.find((extension) => extension.name === name); + if (!extension) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Extension "{{name}}" not found.', { name }), + }, + Date.now(), + ); + return; + } + context.ui.addItem( + { + type: MessageType.INFO, + text: extensionToOutputString( + extension, + extensionManager, + process.cwd(), + true, + ), + }, + Date.now(), + ); +} + export async function completeExtensions( context: CommandContext, partialArg: string, @@ -495,7 +543,10 @@ export async function completeExtensions( name.startsWith(partialArg), ); - if (context.invocation?.name !== 'uninstall') { + if ( + context.invocation?.name !== 'uninstall' && + context.invocation?.name !== 'detail' + ) { if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) { suggestions.unshift('--all'); } @@ -594,6 +645,16 @@ const uninstallCommand: SlashCommand = { completion: completeExtensions, }; +const detailCommand: SlashCommand = { + name: 'detail', + get description() { + return t('Get detail of an extension'); + }, + kind: CommandKind.BUILT_IN, + action: detailAction, + completion: completeExtensions, +}; + export const extensionsCommand: SlashCommand = { name: 'extensions', get description() { @@ -608,6 +669,7 @@ export const extensionsCommand: SlashCommand = { installCommand, uninstallCommand, exploreExtensionsCommand, + detailCommand, ], action: (context, args) => // Default to list if no subcommand is provided diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index a155f0892..51432c43a 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -218,6 +218,30 @@ describe('extension tests', () => { ]); }); + it('should use default QWEN.md when contextFileName is empty array', async () => { + const extDir = path.join(userExtensionsDir, 'ext-empty-context'); + fs.mkdirSync(extDir, { recursive: true }); + fs.writeFileSync( + path.join(extDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ + name: 'ext-empty-context', + version: '1.0.0', + contextFileName: [], + }), + ); + fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context content'); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + const ext = extensions.find((e) => e.config.name === 'ext-empty-context'); + expect(ext?.contextFiles).toEqual([ + path.join(userExtensionsDir, 'ext-empty-context', 'QWEN.md'), + ]); + }); + it('should skip extensions with invalid JSON and log a warning', async () => { const consoleSpy = vi .spyOn(console, 'error') @@ -694,13 +718,14 @@ describe('extension tests', () => { expect(() => validateName('UPPERCASE')).not.toThrow(); }); + it('should accept names with underscores and dots', () => { + expect(() => validateName('my_extension')).not.toThrow(); + expect(() => validateName('my.extension')).not.toThrow(); + expect(() => validateName('my_ext.v1')).not.toThrow(); + expect(() => validateName('ext_1.2.3')).not.toThrow(); + }); + it('should reject names with invalid characters', () => { - expect(() => validateName('my_extension')).toThrow( - 'Invalid extension name', - ); - expect(() => validateName('my.extension')).toThrow( - 'Invalid extension name', - ); expect(() => validateName('my extension')).toThrow( 'Invalid extension name', ); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 07522f300..628dd9b6f 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -190,7 +190,7 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { } function getContextFileNames(config: ExtensionConfig): string[] { - if (!config.contextFileName) { + if (!config.contextFileName || config.contextFileName.length === 0) { return ['QWEN.md']; } else if (!Array.isArray(config.contextFileName)) { return [config.contextFileName]; @@ -1244,9 +1244,9 @@ export function hashValue(value: string): string { } export function validateName(name: string) { - if (!/^[a-zA-Z0-9-]+$/.test(name)) { + if (!/^[a-zA-Z0-9-_.]+$/.test(name)) { 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), underscores (_), dots (.), and dashes (-) are allowed.`, ); } } diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index e98e6498a..87d7d22b7 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -117,6 +117,51 @@ describe('git extension helpers', () => { 'Failed to clone Git repository from http://my-repo.com', ); }); + + it('should use marketplace source for marketplace type extensions', async () => { + const installMetadata = { + source: 'marketplace:my-plugin', + type: 'marketplace' as const, + marketplace: { + pluginName: 'my-plugin', + marketplaceSource: 'https://github.com/marketplace/my-plugin', + }, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/marketplace/my-plugin' }, + }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.clone).toHaveBeenCalledWith( + 'https://github.com/marketplace/my-plugin', + './', + ['--depth', '1'], + ); + }); + + it('should use source for marketplace type without marketplace metadata', async () => { + const installMetadata = { + source: 'http://fallback-repo.com', + type: 'marketplace' as const, + }; + const destination = '/dest'; + mockGit.getRemotes.mockResolvedValue([ + { name: 'origin', refs: { fetch: 'http://fallback-repo.com' } }, + ]); + + await cloneFromGit(installMetadata, destination); + + expect(mockGit.clone).toHaveBeenCalledWith( + 'http://fallback-repo.com', + './', + ['--depth', '1'], + ); + }); }); describe('checkForExtensionUpdate', () => { diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index a89c565b8..c13bcaf16 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -53,7 +53,10 @@ export async function cloneFromGit( ): Promise { try { const git = simpleGit(destination); - let sourceUrl = installMetadata.source; + let sourceUrl = + installMetadata.type === 'marketplace' && installMetadata.marketplace + ? installMetadata.marketplace.marketplaceSource + : installMetadata.source; const token = getGitHubToken(); if (token) { try {