diff --git a/docs/users/extension/_meta.ts b/docs/users/extension/_meta.ts index 386bb68fc..ad072a629 100644 --- a/docs/users/extension/_meta.ts +++ b/docs/users/extension/_meta.ts @@ -1,6 +1,6 @@ export default { introduction: 'Introduction', - 'getting-start-extensions': { + 'getting-started-extensions': { display: 'hidden', }, 'extension-releasing': { diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 64bc7bc7d..1d7160768 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -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 --scope ` | Disable an extension | | `/extensions update ` | Update a specific extension | | `/extensions update --all` | Update all extensions with available updates | +| `/extensions detail ` | 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 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 : +qwen extensions install +# or +qwen extensions install +``` + +If you want to install a specific plugin, you can use the format with plugin name: + +```bash +qwen extensions install : +# or +qwen extensions install : +``` + +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 +# or +qwen extensions install / +``` + +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 `/.qwen/extensions` diff --git a/package-lock.json b/package-lock.json index 2a0726478..b4f0301d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/cli/package.json b/packages/cli/package.json index 03e5d90e9..8c198fc61 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,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", @@ -84,6 +85,7 @@ "@types/semver": "^7.7.0", "@types/shell-quote": "^1.7.5", "@types/yargs": "^17.0.32", + "@types/prompts": "^2.4.9", "archiver": "^7.0.1", "ink-testing-library": "^4.0.0", "jsdom": "^26.1.0", diff --git a/packages/cli/src/commands/extensions/consent.test.ts b/packages/cli/src/commands/extensions/consent.test.ts index 3b9808bc4..7d48a7c8c 100644 --- a/packages/cli/src/commands/extensions/consent.test.ts +++ b/packages/cli/src/commands/extensions/consent.test.ts @@ -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) => { @@ -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.'); + }); +}); diff --git a/packages/cli/src/commands/extensions/consent.ts b/packages/cli/src/commands/extensions/consent.ts index c3da6a282..cfff6e5b7 100644 --- a/packages/cli/src/commands/extensions/consent.ts +++ b/packages/cli/src/commands/extensions/consent.ts @@ -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 { + 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. * diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index bb18392bc..f002d1a12 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -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', () => ({ diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 6a9ce4929..f7fda09df 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -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(); diff --git a/packages/cli/src/commands/extensions/utils.test.ts b/packages/cli/src/commands/extensions/utils.test.ts index 278ee7a54..84050dbfa 100644 --- a/packages/cli/src/commands/extensions/utils.test.ts +++ b/packages/cli/src/commands/extensions/utils.test.ts @@ -32,6 +32,7 @@ vi.mock('../../config/trustedFolders.js', () => ({ vi.mock('./consent.js', () => ({ requestConsentOrFail: vi.fn(), requestConsentNonInteractive: vi.fn(), + requestChoicePluginNonInteractive: vi.fn(), })); describe('getExtensionManager', () => { diff --git a/packages/cli/src/commands/extensions/utils.ts b/packages/cli/src/commands/extensions/utils.ts index 97e7a8d2f..d7d2d0a59 100644 --- a/packages/cli/src/commands/extensions/utils.ts +++ b/packages/cli/src/commands/extensions/utils.ts @@ -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 { null, requestConsentNonInteractive, ), + requestChoicePlugin: requestChoicePluginNonInteractive, isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged), }); await extensionManager.refreshCache(); diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 1dc124c3f..95496b4be 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -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}}': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 929ffc904..cef757a0b 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -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}}': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index c5108ec5d..24fd24b9d 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -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}}': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index d6603207c..4f8c95d88 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -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}}': diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c1bd7b80c..91f580ebf 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -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((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((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, diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 82417fbe1..c68afd420 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -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 ( + + ); + } if (uiState.isThemeDialogOpen) { return ( diff --git a/packages/cli/src/ui/components/PluginChoicePrompt.test.tsx b/packages/cli/src/ui/components/PluginChoicePrompt.test.tsx new file mode 100644 index 000000000..dd1045d67 --- /dev/null +++ b/packages/cli/src/ui/components/PluginChoicePrompt.test.tsx @@ -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( + , + ); + + expect(lastFrame()).toContain('test-marketplace'); + }); + + it('renders plugin names', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('plugin1'); + expect(lastFrame()).toContain('plugin2'); + }); + + it('renders description for selected plugin only', () => { + const { lastFrame } = render( + , + ); + + // First plugin is selected by default, should show its description + expect(lastFrame()).toContain('First plugin description'); + }); + + it('renders help text', () => { + const { lastFrame } = render( + , + ); + + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // Should show progress like "(1/15)" + expect(lastFrame()).toContain('(1/15)'); + }); + }); + + describe('keyboard navigation', () => { + it('registers keypress handler', () => { + render( + , + ); + + expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), { + isActive: true, + }); + }); + + it('calls onCancel when escape is pressed', () => { + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(lastFrame()).toContain('❯'); + }); + }); +}); diff --git a/packages/cli/src/ui/components/PluginChoicePrompt.tsx b/packages/cli/src/ui/components/PluginChoicePrompt.tsx new file mode 100644 index 000000000..ef463bacd --- /dev/null +++ b/packages/cli/src/ui/components/PluginChoicePrompt.tsx @@ -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 ( + + + {t('Select a plugin from "{{name}}"', { name: marketplaceName })} + + + + {/* Show "more items above" indicator */} + {hasLess && ( + + + {' '} + ↑ {t('{{count}} more above', { count: String(startIndex) })} + + + )} + + {visiblePlugins.map((plugin, visibleIndex) => { + const actualIndex = startIndex + visibleIndex; + const isSelected = actualIndex === selectedIndex; + const prefix = isSelected ? '❯ ' : ' '; + + return ( + + + + {prefix} + + + {plugin.name} + + + {/* Show full description only for selected item */} + {isSelected && plugin.description && ( + + {plugin.description} + + )} + + ); + })} + + {/* Show "more items below" indicator */} + {hasMore && ( + + + {' '} + ↓{' '} + {t('{{count}} more below', { + count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS), + })} + + + )} + + + + + {t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')} + + {plugins.length > MAX_VISIBLE_ITEMS && ( + + ({selectedIndex + 1}/{plugins.length}) + + )} + + + ); +}; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e52dc7fd9..f62819527 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -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; diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index bb297b473..bc0906aa3 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -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'); + }); +}); diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.ts index b547698f9..a86f0b814 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.ts @@ -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'], diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index bc1bd3dcd..b111f9ac7 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -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; +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 3dc3da97a..eb6993d85 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 1cbc726c6..224a22b11 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -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; diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 51432c43a..db23038dc 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -20,7 +20,6 @@ import { validateName, getExtensionId, hashValue, - parseInstallSource, type ExtensionConfig, } from './extensionManager.js'; import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; @@ -780,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'); - }); - }); }); }); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 628dd9b6f..921d34739 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -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; requestSetting?: (setting: ExtensionSetting) => Promise; + requestChoicePlugin?: ( + marketplace: ClaudeMarketplaceConfig, + ) => Promise; } // ============================================================================ @@ -274,6 +277,9 @@ export class ExtensionManager { private isWorkspaceTrusted: boolean; private requestConsent: (options?: ExtensionRequestOptions) => Promise; private requestSetting?: (setting: ExtensionSetting) => Promise; + private requestChoicePlugin: ( + marketplace: ClaudeMarketplaceConfig, + ) => Promise; 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, + ): 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); } @@ -1250,38 +1256,3 @@ export function validateName(name: string) { ); } } - -export async function parseInstallSource( - source: string, -): Promise { - 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; -} diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 87d7d22b7..c305317d2 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -118,32 +118,6 @@ describe('git extension helpers', () => { ); }); - 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', diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index c13bcaf16..f5e45a684 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -53,10 +53,7 @@ export async function cloneFromGit( ): Promise { try { const git = simpleGit(destination); - let sourceUrl = - installMetadata.type === 'marketplace' && installMetadata.marketplace - ? installMetadata.marketplace.marketplaceSource - : installMetadata.source; + let sourceUrl = installMetadata.source; const token = getGitHubToken(); if (token) { try { @@ -239,12 +236,8 @@ export async function downloadFromGitHubRelease( installMetadata: ExtensionInstallMetadata, destination: string, ): Promise { - 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); diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts index 2940d5847..10b53da2c 100644 --- a/packages/core/src/extension/index.ts +++ b/packages/core/src/extension/index.ts @@ -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'; diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 5a69bf388..f7900b752 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -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(); }); }); }); diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 35b472683..dec525579 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -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,246 @@ export interface MarketplaceInstallResult { } /** - * Parse marketplace install source string. - * Format: marketplace-url:plugin-name + * Parse the install source string into repo and optional pluginName. + * Format: : 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, +): Promise { + 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 { + 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 = { + '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 = { + '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 { + 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 { + // 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 + try { + const [owner, repoName] = repo.split('/'); + marketplaceConfig = await fetchGitHubMarketplaceConfig(owner, repoName); + } catch { + // Not a valid GitHub URL or failed to fetch, continue without marketplace config + } + } 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; }