Compare commits

..

3 Commits

Author SHA1 Message Date
LaZzyMan
63e24301f8 fix copy error 2026-01-23 16:41:23 +08:00
LaZzyMan
9af9ea259d feat: add select ui for claude marketplace 2026-01-23 16:23:30 +08:00
LaZzyMan
674bb6386e feat(extensions): add detail command and improve extension validation
- Add /extensions detail command to show extension details
- Allow underscores and dots in extension names
- Fix contextFileName empty array handling to use default QWEN.md
- Fix marketplace extension clone to use correct source URL
- Add inline parameter to extensionToOutputString
- Add comprehensive tests for all changes
2026-01-22 19:37:01 +08:00
39 changed files with 1765 additions and 304 deletions

View File

@@ -1,6 +1,6 @@
export default {
introduction: 'Introduction',
'getting-start-extensions': {
'getting-started-extensions': {
display: 'hidden',
},
'extension-releasing': {

View File

@@ -1,8 +1,8 @@
# Qwen Code Extensions
Qwen Code extensions package prompts, MCP servers, and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
Qwen Code extensions package prompts, MCP servers, subagents, skills and custom commands into a familiar and user-friendly format. With extensions, you can expand the capabilities of Qwen Code and share those capabilities with others. They are designed to be easily installable and shareable.
This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions.
Extensions and plugins from [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/) and [Claude Code Marketplace](https://claudemarketplaces.com/) can be directly installed into Qwen Code.This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions.
## Extension management
@@ -21,6 +21,7 @@ You can manage extensions at runtime within the interactive CLI using `/extensio
| `/extensions disable <name> --scope <user\|workspace>` | Disable an extension |
| `/extensions update <name>` | Update a specific extension |
| `/extensions update --all` | Update all extensions with available updates |
| `/extensions detail <name>` | Show details of an extension |
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
### CLI Extension Management
@@ -31,26 +32,30 @@ You can also manage extensions using `qwen extensions` CLI commands. Note that c
You can install an extension using `qwen extensions install` from multiple sources:
#### From Gemini CLI Extensions Marketplace
Qwen Code fully supports extensions from the [Gemini CLI Extensions Marketplace](https://geminicli.com/extensions/). Simply install them using the git URL:
```bash
qwen extensions install <gemini-cli-extension-url>
```
Gemini extensions are automatically converted to Qwen Code format during installation:
- `gemini-extension.json` is converted to `qwen-extension.json`
- TOML command files are automatically migrated to Markdown format
- MCP servers, context files, and settings are preserved
#### From Claude Code Marketplace
Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install them using the marketplace URL format:
Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install from a marketplace and choose a plugin:
```bash
qwen extensions install <claude-code-marketplace-url>:<plugin-name>
qwen extensions install <marketplace-name>
# or
qwen extensions install <marketplace-github-url>
```
If you want to install a specific pulgin, you can use the format with plugin name:
```bash
qwen extensions install <marketplace-name>:<plugin-name>
# or
qwen extensions install <marketplace-github-url>:<plugin-name>
```
For example, to install the `prompts.chat` plugin from the [f/awesome-chatgpt-prompts](https://claudemarketplaces.com/plugins/f-awesome-chatgpt-prompts) marketplace:
```bash
qwen extensions install f/awesome-chatgpt-prompts:prompts.chat
# or
qwen extensions install https://github.com/f/awesome-chatgpt-prompts:prompts.chat
```
Claude plugins are automatically converted to Qwen Code format during installation:
@@ -60,8 +65,36 @@ Claude plugins are automatically converted to Qwen Code format during installati
- Skill configurations are converted to Qwen skill format
- Tool mappings are automatically handled
You can quickly browse available extensions from different marketplaces using the `/extensions explore` command:
```bash
# Open Gemini CLI Extensions marketplace
/extensions explore Gemini
# Open Claude Code marketplace
/extensions explore ClaudeCode
```
This command opens the respective marketplace in your default browser, allowing you to discover new extensions to enhance your Qwen Code experience.
> **Cross-Platform Compatibility**: This allows you to leverage the rich extension ecosystems from both Gemini CLI and Claude Code, dramatically expanding the available functionality for Qwen Code users.
#### From Gemini CLI Extensions
Qwen Code fully supports extensions from the [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/). Simply install them using the git URL:
```bash
qwen extensions install <gemini-cli-extension-github-url>
# or
qwen extensions install <owner>/<repo>
```
Gemini extensions are automatically converted to Qwen Code format during installation:
- `gemini-extension.json` is converted to `qwen-extension.json`
- TOML command files are automatically migrated to Markdown format
- MCP servers, context files, and settings are preserved
#### From Git Repository
```bash
@@ -108,20 +141,6 @@ You can update all extensions with:
qwen extensions update --all
```
### Exploring Extension Marketplaces
You can quickly browse available extensions from different marketplaces using the `/extensions explore` command:
```bash
# Open Gemini CLI Extensions marketplace
/extensions explore Gemini
# Open Claude Code marketplace
/extensions explore ClaudeCode
```
This command opens the respective marketplace in your default browser, allowing you to discover new extensions to enhance your Qwen Code experience.
## How it works
On startup, Qwen Code looks for extensions in `<home>/.qwen/extensions`

3
package-lock.json generated
View File

@@ -3879,7 +3879,6 @@
"version": "2.4.9",
"resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz",
"integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -17349,6 +17348,7 @@
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/qwen-code-core": "file:../core",
"@types/prompts": "^2.4.9",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"command-exists": "^1.2.9",
@@ -17364,6 +17364,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",

View File

@@ -40,6 +40,7 @@
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@qwen-code/qwen-code-core": "file:../core",
"@types/prompts": "^2.4.9",
"@types/update-notifier": "^6.0.8",
"ansi-regex": "^6.2.2",
"command-exists": "^1.2.9",
@@ -55,6 +56,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",

View File

@@ -5,8 +5,16 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { extensionConsentString, requestConsentOrFail } from './consent.js';
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
import {
extensionConsentString,
requestConsentOrFail,
requestChoicePluginNonInteractive,
} from './consent.js';
import type {
ExtensionConfig,
ClaudeMarketplaceConfig,
} from '@qwen-code/qwen-code-core';
import prompts from 'prompts';
vi.mock('../../i18n/index.js', () => ({
t: vi.fn((str: string, params?: Record<string, string>) => {
@@ -20,6 +28,8 @@ vi.mock('../../i18n/index.js', () => ({
}),
}));
vi.mock('prompts');
describe('extensionConsentString', () => {
it('should include extension name', () => {
const config: ExtensionConfig = {
@@ -241,3 +251,72 @@ describe('requestConsentOrFail', () => {
expect(mockRequestConsent).toHaveBeenCalled();
});
});
describe('requestChoicePluginNonInteractive', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should throw error when plugins array is empty', async () => {
const marketplace: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [],
};
await expect(
requestChoicePluginNonInteractive(marketplace),
).rejects.toThrow('No plugins available in this marketplace.');
});
it('should return selected plugin name', async () => {
const marketplace: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{
name: 'plugin1',
description: 'Plugin 1',
version: '1.0.0',
source: 'src1',
},
{
name: 'plugin2',
description: 'Plugin 2',
version: '1.0.0',
source: 'src2',
},
],
};
vi.mocked(prompts).mockResolvedValueOnce({ plugin: 'plugin2' });
const result = await requestChoicePluginNonInteractive(marketplace);
expect(result).toBe('plugin2');
expect(prompts).toHaveBeenCalledWith(
expect.objectContaining({
type: 'select',
name: 'plugin',
choices: expect.arrayContaining([
expect.objectContaining({ value: 'plugin1' }),
expect.objectContaining({ value: 'plugin2' }),
]),
}),
);
});
it('should throw error when selection is cancelled', async () => {
const marketplace: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [{ name: 'plugin1', version: '1.0.0', source: 'src1' }],
};
vi.mocked(prompts).mockResolvedValueOnce({ plugin: undefined });
await expect(
requestChoicePluginNonInteractive(marketplace),
).rejects.toThrow('Plugin selection cancelled.');
});
});

View File

@@ -1,4 +1,5 @@
import type {
ClaudeMarketplaceConfig,
ExtensionConfig,
ExtensionRequestOptions,
SkillConfig,
@@ -6,6 +7,7 @@ import type {
} from '@qwen-code/qwen-code-core';
import type { ConfirmationRequest } from '../../ui/types.js';
import chalk from 'chalk';
import prompts from 'prompts';
import { t } from '../../i18n/index.js';
/**
@@ -27,6 +29,49 @@ export async function requestConsentNonInteractive(
return result;
}
/**
* Requests plugin selection from the user in non-interactive mode.
* Displays an interactive list with arrow key navigation.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param marketplace The marketplace config containing available plugins.
* @returns The name of the selected plugin.
*/
export async function requestChoicePluginNonInteractive(
marketplace: ClaudeMarketplaceConfig,
): Promise<string> {
const plugins = marketplace.plugins;
if (plugins.length === 0) {
throw new Error(t('No plugins available in this marketplace.'));
}
// Build choices for prompts select
const choices = plugins.map((plugin) => ({
title: chalk.green(chalk.bold(`[${plugin.name}]`)),
value: plugin.name,
}));
const response = await prompts({
type: 'select',
name: 'plugin',
message: t('Select a plugin to install from marketplace "{{name}}":', {
name: marketplace.name,
}),
choices,
initial: 0,
});
// Handle cancellation (Ctrl+C)
if (response.plugin === undefined) {
throw new Error(t('Plugin selection cancelled.'));
}
return response.plugin;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*

View File

@@ -35,6 +35,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
vi.mock('./consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
requestConsentOrFail: mockRequestConsentOrFail,
requestChoicePluginNonInteractive: vi.fn(),
}));
vi.mock('../../config/trustedFolders.js', () => ({

View File

@@ -16,6 +16,7 @@ import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
requestChoicePluginNonInteractive,
} from './consent.js';
import { t } from '../../i18n/index.js';
@@ -54,6 +55,7 @@ export async function handleInstall(args: InstallArgs) {
loadSettings(workspaceDir).merged,
),
requestConsent,
requestChoicePlugin: requestChoicePluginNonInteractive,
});
await extensionManager.refreshCache();

View File

@@ -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 = {
@@ -31,6 +32,7 @@ vi.mock('../../config/trustedFolders.js', () => ({
vi.mock('./consent.js', () => ({
requestConsentOrFail: vi.fn(),
requestConsentNonInteractive: vi.fn(),
requestChoicePluginNonInteractive: vi.fn(),
}));
describe('getExtensionManager', () => {
@@ -64,3 +66,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);
});
});

View File

@@ -9,6 +9,7 @@ import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
requestChoicePluginNonInteractive,
} from './consent.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import * as os from 'node:os';
@@ -22,6 +23,7 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
null,
requestConsentNonInteractive,
),
requestChoicePlugin: requestChoicePluginNonInteractive,
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
});
await extensionManager.refreshCache();
@@ -32,6 +34,7 @@ export function extensionToOutputString(
extension: Extension,
extensionManager: ExtensionManager,
workspaceDir: string,
inline = false,
): string {
const cwd = workspaceDir;
const userEnabled = extensionManager.isEnabled(
@@ -44,7 +47,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})`;

View File

@@ -507,6 +507,19 @@ export default {
'Manage extension settings.': 'Erweiterungseinstellungen verwalten.',
'You need to specify a command (set or list).':
'Sie müssen einen Befehl angeben (set oder list).',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.':
'In diesem Marktplatz sind keine Plugins verfügbar.',
'Select a plugin to install from marketplace "{{name}}":':
'Wählen Sie ein Plugin zur Installation aus Marktplatz "{{name}}":',
'Plugin selection cancelled.': 'Plugin-Auswahl abgebrochen.',
'Select a plugin from "{{name}}"': 'Plugin aus "{{name}}" auswählen',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'Verwenden Sie ↑↓ oder j/k zum Navigieren, Enter zum Auswählen, Escape zum Abbrechen',
'{{count}} more above': '{{count}} weitere oben',
'{{count}} more below': '{{count}} weitere unten',
'manage IDE integration': 'IDE-Integration verwalten',
'check status of IDE integration': 'Status der IDE-Integration prüfen',
'install required IDE companion for {{ideName}}':

View File

@@ -515,6 +515,19 @@ export default {
'Manage extension settings.': 'Manage extension settings.',
'You need to specify a command (set or list).':
'You need to specify a command (set or list).',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.':
'No plugins available in this marketplace.',
'Select a plugin to install from marketplace "{{name}}":':
'Select a plugin to install from marketplace "{{name}}":',
'Plugin selection cancelled.': 'Plugin selection cancelled.',
'Select a plugin from "{{name}}"': 'Select a plugin from "{{name}}"',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel',
'{{count}} more above': '{{count}} more above',
'{{count}} more below': '{{count}} more below',
'manage IDE integration': 'manage IDE integration',
'check status of IDE integration': 'check status of IDE integration',
'install required IDE companion for {{ideName}}':

View File

@@ -519,6 +519,19 @@ export default {
'Manage extension settings.': 'Управление настройками расширений.',
'You need to specify a command (set or list).':
'Необходимо указать команду (set или list).',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.':
'В этом маркетплейсе нет доступных плагинов.',
'Select a plugin to install from marketplace "{{name}}":':
'Выберите плагин для установки из маркетплейса "{{name}}":',
'Plugin selection cancelled.': 'Выбор плагина отменён.',
'Select a plugin from "{{name}}"': 'Выберите плагин из "{{name}}"',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'Используйте ↑↓ или j/k для навигации, Enter для выбора, Escape для отмены',
'{{count}} more above': 'ещё {{count}} выше',
'{{count}} more below': 'ещё {{count}} ниже',
'manage IDE integration': 'Управление интеграцией с IDE',
'check status of IDE integration': 'Проверить статус интеграции с IDE',
'install required IDE companion for {{ideName}}':

View File

@@ -490,6 +490,18 @@ export default {
'Manage extension settings.': '管理扩展设置。',
'You need to specify a command (set or list).':
'您需要指定命令set 或 list。',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.': '此市场中没有可用的插件。',
'Select a plugin to install from marketplace "{{name}}":':
'从市场 "{{name}}" 中选择要安装的插件:',
'Plugin selection cancelled.': '插件选择已取消。',
'Select a plugin from "{{name}}"': '从 "{{name}}" 中选择插件',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'使用 ↑↓ 或 j/k 导航回车选择Esc 取消',
'{{count}} more above': '上方还有 {{count}} 项',
'{{count}} more below': '下方还有 {{count}} 项',
'manage IDE integration': '管理 IDE 集成',
'check status of IDE integration': '检查 IDE 集成状态',
'install required IDE companion for {{ideName}}':

View File

@@ -93,6 +93,7 @@ import {
useExtensionUpdates,
useConfirmUpdateRequests,
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
@@ -176,12 +177,34 @@ export const AppContainer = (props: AppContainerProps) => {
const { addSettingInputRequest, settingInputRequests } =
useSettingInputRequests();
const { addPluginChoiceRequest, pluginChoiceRequests } =
usePluginChoiceRequests();
extensionManager.setRequestConsent(
requestConsentOrFail.bind(null, (description) =>
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
),
);
extensionManager.setRequestChoicePlugin(
(marketplace) =>
new Promise<string>((resolve, reject) => {
addPluginChoiceRequest({
marketplaceName: marketplace.name,
plugins: marketplace.plugins.map((p) => ({
name: p.name,
description: p.description,
})),
onSelect: (pluginName) => {
resolve(pluginName);
},
onCancel: () => {
reject(new Error('Plugin selection cancelled'));
},
});
}),
);
extensionManager.setRequestSetting(
(setting) =>
new Promise<string>((resolve, reject) => {
@@ -1307,6 +1330,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
@@ -1369,6 +1393,7 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
@@ -1461,6 +1486,7 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,

View File

@@ -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 <extension-name>',
},
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),
);
});
});
});

View File

@@ -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 <extension-name>'),
},
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

View File

@@ -12,6 +12,7 @@ import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { SettingInputPrompt } from './SettingInputPrompt.js';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
@@ -147,6 +148,19 @@ export const DialogManager = ({
/>
);
}
if (uiState.pluginChoiceRequests.length > 0) {
const request = uiState.pluginChoiceRequests[0];
return (
<PluginChoicePrompt
key={request.marketplaceName}
marketplaceName={request.marketplaceName}
plugins={request.plugins}
onSelect={request.onSelect}
onCancel={request.onCancel}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">

View File

@@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { useKeypress } from '../hooks/useKeypress.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
describe('PluginChoicePrompt', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
const terminalWidth = 80;
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders marketplace name in title', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test-marketplace"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('test-marketplace');
});
it('renders plugin names', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('plugin1');
expect(lastFrame()).toContain('plugin2');
});
it('renders description for selected plugin only', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin description' },
{ name: 'plugin2', description: 'Second plugin description' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// First plugin is selected by default, should show its description
expect(lastFrame()).toContain('First plugin description');
});
it('renders help text', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('↑↓');
expect(lastFrame()).toContain('Enter');
expect(lastFrame()).toContain('Escape');
});
});
describe('scrolling behavior', () => {
it('does not show scroll indicators for small lists', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).not.toContain('more below');
});
it('shows "more below" indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// At the beginning, should show "more below" but not "more above"
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).toContain('more below');
});
it('shows progress indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// Should show progress like "(1/15)"
expect(lastFrame()).toContain('(1/15)');
});
});
describe('keyboard navigation', () => {
it('registers keypress handler', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
});
it('calls onCancel when escape is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape', sequence: '\x1b' } as never);
expect(onCancel).toHaveBeenCalled();
});
it('calls onSelect with plugin name when enter is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'test-plugin' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'return', sequence: '\r' } as never);
expect(onSelect).toHaveBeenCalledWith('test-plugin');
});
it('calls onSelect with correct plugin when number key 1-9 is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: '2', sequence: '2' } as never);
expect(onSelect).toHaveBeenCalledWith('plugin2');
});
});
describe('selection indicator', () => {
it('shows selection indicator for first plugin by default', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }, { name: 'plugin2' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('');
});
});
});

View File

@@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useState, useCallback, useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
interface PluginChoice {
name: string;
description?: string;
}
type PluginChoicePromptProps = {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
terminalWidth: number;
};
// Maximum number of visible items in the list
const MAX_VISIBLE_ITEMS = 8;
export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
const { marketplaceName, plugins, onSelect, onCancel } = props;
const [selectedIndex, setSelectedIndex] = useState(0);
const prefixWidth = 2; // " " or " "
const handleKeypress = useCallback(
(key: Key) => {
const { name, sequence } = key;
if (name === 'escape') {
onCancel();
return;
}
if (name === 'return') {
const plugin = plugins[selectedIndex];
if (plugin) {
onSelect(plugin.name);
}
return;
}
// Navigate up
if (name === 'up' || sequence === 'k') {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
return;
}
// Navigate down
if (name === 'down' || sequence === 'j') {
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
return;
}
// Number shortcuts (1-9)
const num = parseInt(sequence || '', 10);
if (!isNaN(num) && num >= 1 && num <= plugins.length && num <= 9) {
setSelectedIndex(num - 1);
const plugin = plugins[num - 1];
if (plugin) {
onSelect(plugin.name);
}
}
},
[plugins, selectedIndex, onSelect, onCancel],
);
useKeypress(handleKeypress, { isActive: true });
// Calculate visible range for scrolling
const { visiblePlugins, startIndex, hasMore, hasLess } = useMemo(() => {
const total = plugins.length;
if (total <= MAX_VISIBLE_ITEMS) {
return {
visiblePlugins: plugins,
startIndex: 0,
hasMore: false,
hasLess: false,
};
}
// Calculate window position to keep selected item visible
let start = 0;
const halfWindow = Math.floor(MAX_VISIBLE_ITEMS / 2);
if (selectedIndex <= halfWindow) {
// Near the beginning
start = 0;
} else if (selectedIndex >= total - halfWindow) {
// Near the end
start = total - MAX_VISIBLE_ITEMS;
} else {
// In the middle - center on selected
start = selectedIndex - halfWindow;
}
const end = Math.min(start + MAX_VISIBLE_ITEMS, total);
return {
visiblePlugins: plugins.slice(start, end),
startIndex: start,
hasLess: start > 0,
hasMore: end < total,
};
}, [plugins, selectedIndex]);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
width="100%"
>
<Text bold color={theme.text.accent}>
{t('Select a plugin from "{{name}}"', { name: marketplaceName })}
</Text>
<Box marginTop={1} flexDirection="column">
{/* Show "more items above" indicator */}
{hasLess && (
<Box>
<Text dimColor>
{' '}
{t('{{count}} more above', { count: String(startIndex) })}
</Text>
</Box>
)}
{visiblePlugins.map((plugin, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const prefix = isSelected ? ' ' : ' ';
return (
<Box key={plugin.name} flexDirection="column">
<Box flexDirection="row">
<Text color={isSelected ? theme.text.accent : undefined}>
{prefix}
</Text>
<Text
bold={isSelected}
color={isSelected ? theme.text.accent : undefined}
>
{plugin.name}
</Text>
</Box>
{/* Show full description only for selected item */}
{isSelected && plugin.description && (
<Box marginLeft={prefixWidth}>
<Text color={theme.text.accent}>{plugin.description}</Text>
</Box>
)}
</Box>
);
})}
{/* Show "more items below" indicator */}
{hasMore && (
<Box>
<Text dimColor>
{' '}
{' '}
{t('{{count}} more below', {
count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS),
})}
</Text>
</Box>
)}
</Box>
<Box marginTop={1} flexDirection="row" gap={2}>
<Text dimColor>
{t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')}
</Text>
{plugins.length > MAX_VISIBLE_ITEMS && (
<Text dimColor>
({selectedIndex + 1}/{plugins.length})
</Text>
)}
</Box>
</Box>
);
};

View File

@@ -15,6 +15,7 @@ import type {
HistoryItemWithoutId,
StreamingState,
SettingInputRequest,
PluginChoiceRequest,
} from '../types.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
@@ -61,6 +62,7 @@ export interface UIState {
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;

View File

@@ -13,6 +13,7 @@ import {
useExtensionUpdates,
useSettingInputRequests,
useConfirmUpdateRequests,
usePluginChoiceRequests,
} from './useExtensionUpdates.js';
import {
QWEN_DIR,
@@ -490,3 +491,118 @@ describe('useExtensionUpdates', () => {
});
});
});
describe('usePluginChoiceRequests', () => {
it('should add a plugin choice request', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
'test-marketplace',
);
expect(result.current.pluginChoiceRequests[0].plugins).toHaveLength(2);
});
it('should remove a plugin choice request when a plugin is selected', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [{ name: 'plugin1' }],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
// Select a plugin
act(() => {
result.current.pluginChoiceRequests[0].onSelect('plugin1');
});
expect(result.current.pluginChoiceRequests).toHaveLength(0);
expect(onSelect).toHaveBeenCalledWith('plugin1');
expect(onCancel).not.toHaveBeenCalled();
});
it('should remove a plugin choice request when cancelled', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [{ name: 'plugin1' }],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
// Cancel the request
act(() => {
result.current.pluginChoiceRequests[0].onCancel();
});
expect(result.current.pluginChoiceRequests).toHaveLength(0);
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
it('should handle multiple plugin choice requests', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect1 = vi.fn();
const onCancel1 = vi.fn();
const onSelect2 = vi.fn();
const onCancel2 = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'marketplace-1',
plugins: [{ name: 'plugin1' }],
onSelect: onSelect1,
onCancel: onCancel1,
});
result.current.addPluginChoiceRequest({
marketplaceName: 'marketplace-2',
plugins: [{ name: 'plugin2' }],
onSelect: onSelect2,
onCancel: onCancel2,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(2);
// Select from first request
act(() => {
result.current.pluginChoiceRequests[0].onSelect('plugin1');
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
'marketplace-2',
);
expect(onSelect1).toHaveBeenCalledWith('plugin1');
});
});

View File

@@ -17,6 +17,7 @@ import {
MessageType,
type ConfirmationRequest,
type SettingInputRequest,
type PluginChoiceRequest,
} from '../types.js';
import { checkExhaustive } from '../../utils/checks.js';
@@ -144,6 +145,71 @@ export const useSettingInputRequests = () => {
};
};
type PluginChoiceRequestWrapper = {
marketplaceName: string;
plugins: Array<{ name: string; description?: string }>;
onSelect: (pluginName: string) => void;
onCancel: () => void;
};
type PluginChoiceRequestAction =
| { type: 'add'; request: PluginChoiceRequestWrapper }
| { type: 'remove'; request: PluginChoiceRequestWrapper };
function pluginChoiceRequestsReducer(
state: PluginChoiceRequestWrapper[],
action: PluginChoiceRequestAction,
): PluginChoiceRequestWrapper[] {
switch (action.type) {
case 'add':
return [...state, action.request];
case 'remove':
return state.filter((r) => r !== action.request);
default:
checkExhaustive(action);
return state;
}
}
export const usePluginChoiceRequests = () => {
const [pluginChoiceRequests, dispatchPluginChoiceRequests] = useReducer(
pluginChoiceRequestsReducer,
[],
);
const addPluginChoiceRequest = useCallback(
(original: PluginChoiceRequest) => {
const wrappedRequest: PluginChoiceRequestWrapper = {
marketplaceName: original.marketplaceName,
plugins: original.plugins,
onSelect: (pluginName: string) => {
dispatchPluginChoiceRequests({
type: 'remove',
request: wrappedRequest,
});
original.onSelect(pluginName);
},
onCancel: () => {
dispatchPluginChoiceRequests({
type: 'remove',
request: wrappedRequest,
});
original.onCancel();
},
};
dispatchPluginChoiceRequests({
type: 'add',
request: wrappedRequest,
});
},
[dispatchPluginChoiceRequests],
);
return {
addPluginChoiceRequest,
pluginChoiceRequests,
dispatchPluginChoiceRequests,
};
};
export const useExtensionUpdates = (
extensionManager: ExtensionManager,
addItem: UseHistoryManagerReturn['addItem'],

View File

@@ -422,3 +422,15 @@ export interface SettingInputRequest {
onSubmit: (value: string) => void;
onCancel: () => void;
}
export interface PluginChoice {
name: string;
description?: string;
}
export interface PluginChoiceRequest {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
}

View File

@@ -113,6 +113,7 @@ import {
type ModelProvidersConfig,
type AvailableModel,
} from '../models/index.js';
import type { ClaudeMarketplaceConfig } from '../extension/claude-converter.js';
// Re-export types
export type { AnyToolInvocation, FileFilteringOptions, MCPOAuthConfig };
@@ -210,10 +211,8 @@ export interface ExtensionInstallMetadata {
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;
marketplace?: {
marketplaceSource: string;
pluginName: string;
};
marketplaceConfig?: ClaudeMarketplaceConfig;
pluginName?: string;
}
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000;

View File

@@ -290,8 +290,8 @@ export function convertClaudeToQwenConfig(
claudeConfig: ClaudePluginConfig,
): ExtensionConfig {
// Validate required fields
if (!claudeConfig.name || !claudeConfig.version) {
throw new Error('Claude plugin config must have name and version fields');
if (!claudeConfig.name) {
throw new Error('Claude plugin config must have name field');
}
// Parse MCP servers
@@ -386,7 +386,7 @@ export async function convertClaudePluginPackage(
}
// Step 3: Load and merge plugin.json if exists (based on strict mode)
const strict = marketplacePlugin.strict ?? true;
const strict = marketplacePlugin.strict ?? false;
let mergedConfig: ClaudePluginConfig;
if (strict) {
@@ -583,7 +583,7 @@ export function mergeClaudeConfigs(
marketplacePlugin: ClaudeMarketplacePluginConfig,
pluginConfig?: ClaudePluginConfig,
): ClaudePluginConfig {
if (!pluginConfig && marketplacePlugin.strict !== false) {
if (!pluginConfig && marketplacePlugin.strict === true) {
throw new Error(
`Plugin ${marketplacePlugin.name} requires plugin.json (strict mode)`,
);
@@ -709,6 +709,12 @@ async function resolvePluginSource(
throw new Error(`Plugin source not found at ${sourcePath}`);
}
// If source path equals marketplace dir (source is '.' or ''),
// return marketplaceDir directly to avoid copying to subdirectory of self
if (path.resolve(sourcePath) === path.resolve(marketplaceDir)) {
return marketplaceDir;
}
// Copy to plugin directory
await fs.promises.cp(sourcePath, pluginDir, { recursive: true });
return pluginDir;

View File

@@ -20,7 +20,6 @@ import {
validateName,
getExtensionId,
hashValue,
parseInstallSource,
type ExtensionConfig,
} from './extensionManager.js';
import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js';
@@ -218,6 +217,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 +717,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',
);
@@ -755,46 +779,5 @@ describe('extension tests', () => {
expect(id).toBe(hashValue('https://github.com/owner/repo'));
});
});
describe('parseInstallSource', () => {
it('should parse HTTPS URL as git type', async () => {
const result = await parseInstallSource(
'https://github.com/owner/repo',
);
expect(result.type).toBe('git');
expect(result.source).toBe('https://github.com/owner/repo');
});
it('should parse HTTP URL as git type', async () => {
const result = await parseInstallSource('http://example.com/repo');
expect(result.type).toBe('git');
});
it('should parse git@ URL as git type', async () => {
const result = await parseInstallSource(
'git@github.com:owner/repo.git',
);
expect(result.type).toBe('git');
});
it('should parse sso:// URL as git type', async () => {
const result = await parseInstallSource('sso://some/path');
expect(result.type).toBe('git');
});
it('should parse marketplace URL correctly', async () => {
const result = await parseInstallSource(
'https://example.com/marketplace:plugin-name',
);
expect(result.type).toBe('marketplace');
expect(result.marketplace?.pluginName).toBe('plugin-name');
});
it('should throw for non-existent local path', async () => {
await expect(
parseInstallSource('/nonexistent/path/to/extension'),
).rejects.toThrow('Install source not found');
});
});
});
});

View File

@@ -9,6 +9,7 @@ import type {
ExtensionInstallMetadata,
SkillConfig,
SubagentConfig,
ClaudeMarketplaceConfig,
} from '../index.js';
import {
Storage,
@@ -21,6 +22,7 @@ import {
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { getErrorMessage } from '../utils/errors.js';
import {
EXTENSIONS_CONFIG_FILENAME,
@@ -36,11 +38,11 @@ import {
} from './github.js';
import type { LoadExtensionContext } from './variableSchema.js';
import { Override, type AllExtensionsEnablementConfig } from './override.js';
import { parseMarketplaceSource } from './marketplace.js';
import {
isGeminiExtensionConfig,
convertGeminiExtensionPackage,
} from './gemini-converter.js';
import { convertClaudePluginPackage } from './claude-converter.js';
import { glob } from 'glob';
import { createHash } from 'node:crypto';
import { ExtensionStorage } from './storage.js';
@@ -62,9 +64,7 @@ import {
ExtensionUninstallEvent,
ExtensionUpdateEvent,
} from '../telemetry/types.js';
import { stat } from 'node:fs/promises';
import { loadSkillsFromDir } from '../skills/skill-load.js';
import { convertClaudePluginPackage } from './claude-converter.js';
import { loadSubagentFromDir } from '../subagents/subagent-manager.js';
// ============================================================================
@@ -151,6 +151,9 @@ export interface ExtensionManagerOptions {
config?: Config;
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>;
requestSetting?: (setting: ExtensionSetting) => Promise<string>;
requestChoicePlugin?: (
marketplace: ClaudeMarketplaceConfig,
) => Promise<string>;
}
// ============================================================================
@@ -190,7 +193,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];
@@ -274,6 +277,9 @@ export class ExtensionManager {
private isWorkspaceTrusted: boolean;
private requestConsent: (options?: ExtensionRequestOptions) => Promise<void>;
private requestSetting?: (setting: ExtensionSetting) => Promise<string>;
private requestChoicePlugin: (
marketplace: ClaudeMarketplaceConfig,
) => Promise<string>;
constructor(options: ExtensionManagerOptions) {
this.workspaceDir = options.workspaceDir ?? process.cwd();
@@ -286,6 +292,8 @@ export class ExtensionManager {
'extension-enablement.json',
);
this.requestSetting = options.requestSetting;
this.requestChoicePlugin =
options.requestChoicePlugin || (() => Promise.resolve(''));
this.requestConsent = options.requestConsent || (() => Promise.resolve());
this.config = options.config;
this.telemetrySettings = options.telemetrySettings;
@@ -308,6 +316,14 @@ export class ExtensionManager {
this.requestSetting = requestSetting;
}
setRequestChoicePlugin(
requestChoicePlugin: (
marketplace: ClaudeMarketplaceConfig,
) => Promise<string>,
): void {
this.requestChoicePlugin = requestChoicePlugin;
}
// ==========================================================================
// Enablement functionality (directly implemented)
// ==========================================================================
@@ -672,9 +688,9 @@ export class ExtensionManager {
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name || !config.version) {
if (!config.name) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
`Invalid configuration in ${configFilePath}: missing "name"}`,
);
}
validateName(config.name);
@@ -734,35 +750,20 @@ export class ExtensionManager {
}
let tempDir: string | undefined;
let claudePluginName: string | undefined;
// Handle marketplace installation
if (installMetadata.type === 'marketplace') {
const marketplaceParsed = parseMarketplaceSource(
installMetadata.source,
if (
installMetadata.type === 'marketplace' &&
installMetadata.marketplaceConfig &&
!installMetadata.pluginName
) {
const pluginName = await this.requestChoicePlugin(
installMetadata.marketplaceConfig,
);
if (!marketplaceParsed) {
throw new Error(
`Invalid marketplace source format: ${installMetadata.source}. Expected format: marketplace-url:plugin-name`,
);
}
installMetadata.pluginName = pluginName;
}
tempDir = await ExtensionStorage.createTmpDir();
try {
await downloadFromGitHubRelease(
{
source: marketplaceParsed.marketplaceSource,
type: 'git',
},
tempDir,
);
} catch (_error) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
}
localSourcePath = tempDir;
claudePluginName = marketplaceParsed.pluginName;
} else if (
if (
installMetadata.type === 'marketplace' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
@@ -772,11 +773,21 @@ export class ExtensionManager {
installMetadata,
tempDir,
);
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
}
} catch (_error) {
await cloneFromGit(installMetadata, tempDir);
installMetadata.type = 'git';
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
installMetadata.type = 'git';
}
}
localSourcePath = tempDir;
} else if (
@@ -791,7 +802,7 @@ export class ExtensionManager {
try {
localSourcePath = await convertGeminiOrClaudeExtension(
localSourcePath,
claudePluginName,
installMetadata.pluginName,
);
newExtensionConfig = this.loadExtensionConfig({
extensionDir: localSourcePath,
@@ -897,12 +908,7 @@ export class ExtensionManager {
);
}
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release' ||
installMetadata.type === 'marketplace'
) {
if (installMetadata.type !== 'link') {
await copyExtension(localSourcePath, destinationPath);
}
@@ -1244,44 +1250,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.`,
);
}
}
export async function parseInstallSource(
source: string,
): Promise<ExtensionInstallMetadata> {
let installMetadata: ExtensionInstallMetadata;
const marketplaceParsed = parseMarketplaceSource(source);
if (marketplaceParsed) {
installMetadata = {
source,
type: 'marketplace',
marketplace: marketplaceParsed,
};
} else if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
) {
installMetadata = {
source,
type: 'git',
};
} else {
try {
await stat(source);
installMetadata = {
source,
type: 'local',
};
} catch {
throw new Error('Install source not found.');
}
}
return installMetadata;
}

View File

@@ -117,6 +117,25 @@ describe('git extension helpers', () => {
'Failed to clone Git repository from http://my-repo.com',
);
});
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', () => {

View File

@@ -236,12 +236,8 @@ export async function downloadFromGitHubRelease(
installMetadata: ExtensionInstallMetadata,
destination: string,
): Promise<GitHubDownloadResult> {
const { source, ref, marketplace, type } = installMetadata;
const { owner, repo } = parseGitHubRepoForReleases(
type === 'marketplace' && marketplace
? marketplace.marketplaceSource
: source,
);
const { source, ref } = installMetadata;
const { owner, repo } = parseGitHubRepoForReleases(source);
try {
const releaseData = await fetchReleaseFromGithub(owner, repo, ref);

View File

@@ -2,3 +2,5 @@ export * from './extensionManager.js';
export * from './variables.js';
export * from './github.js';
export * from './extensionSettings.js';
export * from './marketplace.js';
export * from './claude-converter.js';

View File

@@ -4,75 +4,208 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { parseMarketplaceSource } from './marketplace.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { parseInstallSource } from './marketplace.js';
import * as fs from 'node:fs/promises';
import * as https from 'node:https';
describe('Marketplace Installation', () => {
describe('parseMarketplaceSource', () => {
it('should parse valid marketplace source with http URL', () => {
const result = parseMarketplaceSource(
'http://example.com/marketplace:my-plugin',
// Mock dependencies
vi.mock('node:fs/promises', () => ({
stat: vi.fn(),
}));
vi.mock('node:fs', () => ({
promises: {
readFile: vi.fn(),
},
}));
vi.mock('node:https', () => ({
get: vi.fn(),
}));
vi.mock('./github.js', () => ({
parseGitHubRepoForReleases: vi.fn((url: string) => {
const match = url.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) {
return { owner: match[1], repo: match[2] };
}
throw new Error('Not a GitHub URL');
}),
}));
describe('parseInstallSource', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default: HTTPS requests fail (no marketplace config)
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
const mockRes = {
statusCode: 404,
on: vi.fn(),
};
if (typeof callback === 'function') {
callback(mockRes as never);
}
return { on: vi.fn() } as never;
});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('owner/repo format parsing', () => {
it('should parse owner/repo format without plugin name', async () => {
const result = await parseInstallSource('owner/repo');
expect(result.source).toBe('https://github.com/owner/repo');
expect(result.type).toBe('git');
expect(result.pluginName).toBeUndefined();
});
it('should parse owner/repo format with plugin name', async () => {
const result = await parseInstallSource('owner/repo:my-plugin');
expect(result.source).toBe('https://github.com/owner/repo');
expect(result.type).toBe('git');
expect(result.pluginName).toBe('my-plugin');
});
it('should handle owner/repo with dashes and underscores', async () => {
const result = await parseInstallSource('my-org/my_repo:plugin-name');
expect(result.source).toBe('https://github.com/my-org/my_repo');
expect(result.pluginName).toBe('plugin-name');
});
});
describe('HTTPS URL parsing', () => {
it('should parse HTTPS GitHub URL without plugin name', async () => {
const result = await parseInstallSource('https://github.com/owner/repo');
expect(result.source).toBe('https://github.com/owner/repo');
expect(result.type).toBe('git');
expect(result.pluginName).toBeUndefined();
});
it('should parse HTTPS GitHub URL with plugin name', async () => {
const result = await parseInstallSource(
'https://github.com/owner/repo:my-plugin',
);
expect(result).toEqual({
marketplaceSource: 'http://example.com/marketplace',
pluginName: 'my-plugin',
});
expect(result.source).toBe('https://github.com/owner/repo');
expect(result.type).toBe('git');
expect(result.pluginName).toBe('my-plugin');
});
it('should parse valid marketplace source with https URL', () => {
const result = parseMarketplaceSource(
'https://github.com/example/marketplace:awesome-plugin',
it('should not treat port number as plugin name', async () => {
const result = await parseInstallSource('https://example.com:8080/repo');
expect(result.source).toBe('https://example.com:8080/repo');
expect(result.pluginName).toBeUndefined();
});
});
describe('git@ URL parsing', () => {
it('should parse git@ URL without plugin name', async () => {
const result = await parseInstallSource('git@github.com:owner/repo.git');
expect(result.source).toBe('git@github.com:owner/repo.git');
expect(result.type).toBe('git');
expect(result.pluginName).toBeUndefined();
});
it('should parse git@ URL with plugin name', async () => {
const result = await parseInstallSource(
'git@github.com:owner/repo.git:my-plugin',
);
expect(result).toEqual({
marketplaceSource: 'https://github.com/example/marketplace',
pluginName: 'awesome-plugin',
});
expect(result.source).toBe('git@github.com:owner/repo.git');
expect(result.type).toBe('git');
expect(result.pluginName).toBe('my-plugin');
});
});
describe('local path parsing', () => {
it('should parse local path without plugin name', async () => {
vi.mocked(fs.stat).mockResolvedValueOnce({} as never);
const result = await parseInstallSource('/path/to/extension');
expect(result.source).toBe('/path/to/extension');
expect(result.type).toBe('local');
expect(result.pluginName).toBeUndefined();
});
it('should handle plugin names with hyphens', () => {
const result = parseMarketplaceSource(
'https://example.com:my-super-plugin',
it('should parse local path with plugin name', async () => {
vi.mocked(fs.stat).mockResolvedValueOnce({} as never);
const result = await parseInstallSource('/path/to/extension:my-plugin');
expect(result.source).toBe('/path/to/extension');
expect(result.type).toBe('local');
expect(result.pluginName).toBe('my-plugin');
});
it('should throw error for non-existent local path', async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
await expect(parseInstallSource('/nonexistent/path')).rejects.toThrow(
'Install source not found: /nonexistent/path',
);
expect(result).toEqual({
marketplaceSource: 'https://example.com',
pluginName: 'my-super-plugin',
});
it('should handle Windows drive letter correctly', async () => {
vi.mocked(fs.stat).mockResolvedValueOnce({} as never);
const result = await parseInstallSource('C:\\path\\to\\extension');
expect(result.source).toBe('C:\\path\\to\\extension');
expect(result.type).toBe('local');
// The colon after C should not be treated as plugin separator
expect(result.pluginName).toBeUndefined();
});
});
describe('marketplace config detection', () => {
it('should detect marketplace type when config exists', async () => {
const mockMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner' },
plugins: [{ name: 'plugin1' }],
};
// Mock successful API response
vi.mocked(https.get).mockImplementation((_url, _options, callback) => {
const mockRes = {
statusCode: 200,
on: vi.fn((event, handler) => {
if (event === 'data') {
handler(Buffer.from(JSON.stringify(mockMarketplaceConfig)));
}
if (event === 'end') {
handler();
}
}),
};
if (typeof callback === 'function') {
callback(mockRes as never);
}
return { on: vi.fn() } as never;
});
const result = await parseInstallSource('owner/repo');
expect(result.type).toBe('marketplace');
expect(result.marketplaceConfig).toEqual(mockMarketplaceConfig);
});
it('should handle URLs with ports', () => {
const result = parseMarketplaceSource(
'https://example.com:8080/marketplace:plugin',
);
expect(result).toEqual({
marketplaceSource: 'https://example.com:8080/marketplace',
pluginName: 'plugin',
});
});
it('should remain git type when marketplace config not found', async () => {
// HTTPS returns 404 (default mock behavior)
const result = await parseInstallSource('owner/repo');
it('should return null for source without colon separator', () => {
const result = parseMarketplaceSource('https://example.com/plugin');
expect(result).toBeNull();
});
it('should return null for source without URL', () => {
const result = parseMarketplaceSource('not-a-url:plugin');
expect(result).toBeNull();
});
it('should return null for source with empty plugin name', () => {
const result = parseMarketplaceSource('https://example.com:');
expect(result).toBeNull();
});
it('should use last colon as separator', () => {
// URLs with ports have colons, should use the last one
const result = parseMarketplaceSource(
'https://example.com:8080:my-plugin',
);
expect(result).toEqual({
marketplaceSource: 'https://example.com:8080',
pluginName: 'my-plugin',
});
expect(result.type).toBe('git');
expect(result.marketplaceConfig).toBeUndefined();
});
});
});

View File

@@ -4,15 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
/**
* This module handles installation of extensions from Claude marketplaces.
*
* A marketplace URL format: marketplace-url:plugin-name
* Example: https://github.com/example/marketplace:my-plugin
*/
import type { ExtensionConfig } from './extensionManager.js';
import type { ExtensionInstallMetadata } from '../config/config.js';
import type { ClaudeMarketplaceConfig } from './claude-converter.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as https from 'node:https';
import { stat } from 'node:fs/promises';
import { parseGitHubRepoForReleases } from './github.js';
export interface MarketplaceInstallOptions {
marketplaceUrl: string;
@@ -28,34 +27,242 @@ export interface MarketplaceInstallResult {
}
/**
* Parse marketplace install source string.
* Format: marketplace-url:plugin-name
* Parse the install source string into repo and optional pluginName.
* Format: <repo>:<pluginName> where pluginName is optional
* The colon separator is only treated as a pluginName delimiter when:
* - It's not part of a URL scheme (http://, https://, git@, sso://)
* - It appears after the repo portion
*/
export function parseMarketplaceSource(source: string): {
marketplaceSource: string;
pluginName: string;
} | null {
// Check if source contains a colon separator
const lastColonIndex = source.lastIndexOf(':');
if (lastColonIndex === -1) {
return null;
function parseSourceAndPluginName(source: string): {
repo: string;
pluginName?: string;
} {
// Check if source contains a colon that could be a pluginName separator
// We need to handle URL schemes that contain colons
const urlSchemes = ['http://', 'https://', 'git@', 'sso://'];
let repoEndIndex = source.length;
let hasPluginName = false;
// For URLs, find the last colon after the scheme
for (const scheme of urlSchemes) {
if (source.startsWith(scheme)) {
const afterScheme = source.substring(scheme.length);
const lastColonIndex = afterScheme.lastIndexOf(':');
if (lastColonIndex !== -1) {
// Check if what follows the colon looks like a pluginName (not a port number or path)
const potentialPluginName = afterScheme.substring(lastColonIndex + 1);
// Plugin name should not contain '/' and should not be a number (port)
if (
potentialPluginName &&
!potentialPluginName.includes('/') &&
!/^\d+/.test(potentialPluginName)
) {
repoEndIndex = scheme.length + lastColonIndex;
hasPluginName = true;
}
}
break;
}
}
// Split at the last colon to separate URL from plugin name
const marketplaceSource = source.substring(0, lastColonIndex);
const pluginName = source.substring(lastColonIndex + 1);
// Validate that marketplace URL looks like a URL
// For non-URL sources (local paths or owner/repo format)
if (
!marketplaceSource.startsWith('http://') &&
!marketplaceSource.startsWith('https://')
repoEndIndex === source.length &&
!urlSchemes.some((s) => source.startsWith(s))
) {
return null;
const lastColonIndex = source.lastIndexOf(':');
// On Windows, avoid treating drive letter as pluginName separator (e.g., C:\path)
if (lastColonIndex > 1) {
repoEndIndex = lastColonIndex;
hasPluginName = true;
}
}
if (!pluginName || pluginName.length === 0) {
return null;
if (hasPluginName) {
return {
repo: source.substring(0, repoEndIndex),
pluginName: source.substring(repoEndIndex + 1),
};
}
return { marketplaceSource, pluginName };
return { repo: source };
}
/**
* Check if a string matches the owner/repo format (e.g., "anthropics/skills")
*/
function isOwnerRepoFormat(source: string): boolean {
// owner/repo format: word/word, no slashes before, no protocol
const ownerRepoRegex = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
return ownerRepoRegex.test(source);
}
/**
* Convert owner/repo format to GitHub HTTPS URL
*/
function convertOwnerRepoToGitHubUrl(ownerRepo: string): string {
return `https://github.com/${ownerRepo}`;
}
/**
* Check if source is a git URL
*/
function isGitUrl(source: string): boolean {
return (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
);
}
/**
* Fetch content from a URL
*/
function fetchUrl(
url: string,
headers: Record<string, string>,
): Promise<string | null> {
return new Promise((resolve) => {
https
.get(url, { headers }, (res) => {
if (res.statusCode !== 200) {
resolve(null);
return;
}
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
resolve(Buffer.concat(chunks).toString());
});
})
.on('error', () => resolve(null));
});
}
/**
* Fetch marketplace config from GitHub repository.
* Primary: GitHub API (supports private repos with token)
* Fallback: raw.githubusercontent.com (no rate limit for public repos)
*/
async function fetchGitHubMarketplaceConfig(
owner: string,
repo: string,
): Promise<ClaudeMarketplaceConfig | null> {
const token = process.env['GITHUB_TOKEN'];
// Primary: GitHub API (works for private repos, but has rate limits)
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/contents/.claude-plugin/marketplace.json`;
const apiHeaders: Record<string, string> = {
'User-Agent': 'qwen-code',
Accept: 'application/vnd.github.v3.raw',
};
if (token) {
apiHeaders['Authorization'] = `token ${token}`;
}
let content = await fetchUrl(apiUrl, apiHeaders);
// Fallback: raw.githubusercontent.com (no rate limit, public repos only)
if (!content) {
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/HEAD/.claude-plugin/marketplace.json`;
const rawHeaders: Record<string, string> = {
'User-Agent': 'qwen-code',
};
content = await fetchUrl(rawUrl, rawHeaders);
}
if (!content) {
return null;
}
try {
return JSON.parse(content) as ClaudeMarketplaceConfig;
} catch {
return null;
}
}
/**
* Read marketplace config from local path
*/
async function readLocalMarketplaceConfig(
localPath: string,
): Promise<ClaudeMarketplaceConfig | null> {
const marketplaceConfigPath = path.join(
localPath,
'.claude-plugin',
'marketplace.json',
);
try {
const content = await fs.promises.readFile(marketplaceConfigPath, 'utf-8');
return JSON.parse(content) as ClaudeMarketplaceConfig;
} catch {
return null;
}
}
export async function parseInstallSource(
source: string,
): Promise<ExtensionInstallMetadata> {
// Step 1: Parse source into repo and optional pluginName
const { repo, pluginName } = parseSourceAndPluginName(source);
let installMetadata: ExtensionInstallMetadata;
let repoSource = repo;
let marketplaceConfig: ClaudeMarketplaceConfig | null = null;
// Step 2: Determine repo type and convert owner/repo format if needed
if (isGitUrl(repo)) {
// Git URL (http://, https://, git@, sso://)
installMetadata = {
source: repoSource,
type: 'git',
pluginName,
};
// Try to fetch marketplace config from GitHub
try {
const { owner, repo: repoName } = parseGitHubRepoForReleases(repoSource);
marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName);
} catch {
// Not a valid GitHub URL or failed to fetch, continue without marketplace config
}
} else if (isOwnerRepoFormat(repo)) {
// owner/repo format - convert to GitHub URL
repoSource = convertOwnerRepoToGitHubUrl(repo);
installMetadata = {
source: repoSource,
type: 'git',
pluginName,
};
// Try to fetch marketplace config from GitHub
const [owner, repoName] = repo.split('/');
marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName);
} else {
// Local path
try {
await stat(repo);
installMetadata = {
source: repo,
type: 'local',
pluginName,
};
// Try to read marketplace config from local path
marketplaceConfig = await readLocalMarketplaceConfig(repo);
} catch {
throw new Error(`Install source not found: ${repo}`);
}
}
// Step 3: If marketplace config exists, update type to marketplace
if (marketplaceConfig) {
installMetadata.type = 'marketplace';
installMetadata.marketplaceConfig = marketplaceConfig;
}
return installMetadata;
}

View File

@@ -1,14 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export interface PermissionResponsePayload {
optionId: string;
}
export interface PermissionResponseMessage {
type: string;
data: PermissionResponsePayload;
}

View File

@@ -431,7 +431,6 @@ export const App: React.FC = () => {
type: 'permissionResponse',
data: { optionId },
});
setPermissionRequest(null);
},
[vscode],

View File

@@ -6,7 +6,6 @@
import type { QwenAgentManager } from '../services/qwenAgentManager.js';
import type { ConversationStore } from '../services/conversationStore.js';
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
import { MessageRouter } from './handlers/MessageRouter.js';
/**
@@ -56,7 +55,7 @@ export class MessageHandler {
* Set permission handler
*/
setPermissionHandler(
handler: (message: PermissionResponseMessage) => void,
handler: (message: { type: string; data: { optionId: string } }) => void,
): void {
this.router.setPermissionHandler(handler);
}

View File

@@ -8,7 +8,6 @@ import * as vscode from 'vscode';
import { QwenAgentManager } from '../services/qwenAgentManager.js';
import { ConversationStore } from '../services/conversationStore.js';
import type { AcpPermissionRequest } from '../types/acpTypes.js';
import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js';
import { PanelManager } from '../webview/PanelManager.js';
import { MessageHandler } from '../webview/MessageHandler.js';
import { WebViewContent } from '../webview/WebViewContent.js';
@@ -252,7 +251,10 @@ export class WebViewProvider {
}
}
};
const handler = (message: PermissionResponseMessage) => {
const handler = (message: {
type: string;
data: { optionId: string };
}) => {
if (message.type !== 'permissionResponse') {
return;
}
@@ -268,16 +270,6 @@ export class WebViewProvider {
optionId.toLowerCase().includes('reject');
if (isCancel) {
// Close any open qwen-diff editors first
try {
void vscode.commands.executeCommand('qwen.diff.closeAll');
} catch (err) {
console.warn(
'[WebViewProvider] Failed to close diffs after reject:',
err,
);
}
// Fire and forget do not block the ACP resolve
(async () => {
try {
@@ -304,6 +296,7 @@ export class WebViewProvider {
const title =
(request.toolCall as { title?: string } | undefined)
?.title || '';
// Normalize kind for UI fall back to 'execute'
let kind = ((
request.toolCall as { kind?: string } | undefined
)?.kind || 'execute') as string;
@@ -326,6 +319,7 @@ export class WebViewProvider {
title,
kind,
status: 'failed',
// Best-effort pass-through (used by UI hints)
rawInput: (request.toolCall as { rawInput?: unknown })
?.rawInput,
locations: (

View File

@@ -24,7 +24,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
onClose,
}) => {
const [focusedIndex, setFocusedIndex] = useState(0);
const [customMessage, setCustomMessage] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
// Correct the ref type for custom input to HTMLInputElement to avoid subsequent forced casting
const customInputRef = useRef<HTMLInputElement>(null);
console.log('PermissionDrawer rendered with isOpen:', isOpen, toolCall);
// Prefer file name from locations, fall back to content[].path if present
@@ -91,7 +94,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
// Number keys 1-9 for quick select
const numMatch = e.key.match(/^[1-9]$/);
if (numMatch) {
if (
numMatch &&
!customInputRef.current?.contains(document.activeElement)
) {
const index = parseInt(e.key, 10) - 1;
if (index < options.length) {
e.preventDefault();
@@ -103,10 +109,7 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
// Arrow keys for navigation
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (options.length === 0) {
return;
}
const totalItems = options.length;
const totalItems = options.length + 1; // +1 for custom input
if (e.key === 'ArrowDown') {
setFocusedIndex((prev) => (prev + 1) % totalItems);
} else {
@@ -115,7 +118,10 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
}
// Enter to select
if (e.key === 'Enter') {
if (
e.key === 'Enter' &&
!customInputRef.current?.contains(document.activeElement)
) {
e.preventDefault();
if (focusedIndex < options.length) {
onResponse(options[focusedIndex].optionId);
@@ -228,6 +234,28 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
</button>
);
})}
{/* Custom message input (extracted component) */}
{(() => {
const isFocused = focusedIndex === options.length;
const rejectOptionId = options.find((o) =>
o.kind.includes('reject'),
)?.optionId;
return (
<CustomMessageInputRow
isFocused={isFocused}
customMessage={customMessage}
setCustomMessage={setCustomMessage}
onFocusRow={() => setFocusedIndex(options.length)}
onSubmitReject={() => {
if (rejectOptionId) {
onResponse(rejectOptionId);
}
}}
inputRef={customInputRef}
/>
);
})()}
</div>
</div>
@@ -235,3 +263,50 @@ export const PermissionDrawer: React.FC<PermissionDrawerProps> = ({
</div>
);
};
/**
* CustomMessageInputRow: Reusable custom input row component (without hooks)
*/
interface CustomMessageInputRowProps {
isFocused: boolean;
customMessage: string;
setCustomMessage: (val: string) => void;
onFocusRow: () => void; // Set focus when mouse enters or input box is focused
onSubmitReject: () => void; // Triggered when Enter is pressed (selecting reject option)
inputRef: React.RefObject<HTMLInputElement | null>;
}
const CustomMessageInputRow: React.FC<CustomMessageInputRowProps> = ({
isFocused,
customMessage,
setCustomMessage,
onFocusRow,
onSubmitReject,
inputRef,
}) => (
<div
className={`flex items-center gap-2 px-2 py-1.5 text-left w-full box-border rounded-[4px] border-0 shadow-[inset_0_0_0_1px_var(--app-transparent-inner-border)] cursor-text text-[var(--app-primary-foreground)] ${
isFocused ? 'text-[var(--app-list-active-foreground)]' : ''
}`}
onMouseEnter={onFocusRow}
onClick={() => inputRef.current?.focus()}
>
<input
ref={inputRef as React.LegacyRef<HTMLInputElement> | undefined}
type="text"
placeholder="Tell Qwen what to do instead"
spellCheck={false}
className="flex-1 bg-transparent border-0 outline-none text-sm placeholder:opacity-70"
style={{ color: 'var(--app-input-foreground)' }}
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
onFocus={onFocusRow}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey && customMessage.trim()) {
e.preventDefault();
onSubmitReject();
}
}}
/>
</div>
);

View File

@@ -7,7 +7,6 @@
import type { IMessageHandler } from './BaseMessageHandler.js';
import type { QwenAgentManager } from '../../services/qwenAgentManager.js';
import type { ConversationStore } from '../../services/conversationStore.js';
import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js';
import { SessionMessageHandler } from './SessionMessageHandler.js';
import { FileMessageHandler } from './FileMessageHandler.js';
import { EditorMessageHandler } from './EditorMessageHandler.js';
@@ -23,7 +22,7 @@ export class MessageRouter {
private authHandler: AuthMessageHandler;
private currentConversationId: string | null = null;
private permissionHandler:
| ((message: PermissionResponseMessage) => void)
| ((message: { type: string; data: { optionId: string } }) => void)
| null = null;
constructor(
@@ -81,7 +80,9 @@ export class MessageRouter {
// Handle permission response specially
if (message.type === 'permissionResponse') {
if (this.permissionHandler) {
this.permissionHandler(message as PermissionResponseMessage);
this.permissionHandler(
message as { type: string; data: { optionId: string } },
);
}
return;
}
@@ -130,7 +131,7 @@ export class MessageRouter {
* Set permission handler
*/
setPermissionHandler(
handler: (message: PermissionResponseMessage) => void,
handler: (message: { type: string; data: { optionId: string } }) => void,
): void {
this.permissionHandler = handler;
}