feat: add select ui for claude marketplace

This commit is contained in:
LaZzyMan 2026-01-23 16:23:30 +08:00
parent 674bb6386e
commit 9af9ea259d
31 changed files with 1388 additions and 286 deletions

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

@ -32,6 +32,7 @@ vi.mock('../../config/trustedFolders.js', () => ({
vi.mock('./consent.js', () => ({
requestConsentOrFail: vi.fn(),
requestConsentNonInteractive: vi.fn(),
requestChoicePluginNonInteractive: vi.fn(),
}));
describe('getExtensionManager', () => {

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();