mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-23 17:26:23 +00:00
Compare commits
3 Commits
fix/edit-f
...
feat/exten
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e24301f8 | ||
|
|
9af9ea259d | ||
|
|
674bb6386e |
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
introduction: 'Introduction',
|
||||
'getting-start-extensions': {
|
||||
'getting-started-extensions': {
|
||||
display: 'hidden',
|
||||
},
|
||||
'extension-releasing': {
|
||||
|
||||
@@ -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
3
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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}}':
|
||||
|
||||
@@ -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}}':
|
||||
|
||||
@@ -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}}':
|
||||
|
||||
@@ -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}}':
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
243
packages/cli/src/ui/components/PluginChoicePrompt.test.tsx
Normal file
243
packages/cli/src/ui/components/PluginChoicePrompt.test.tsx
Normal 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('❯');
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/cli/src/ui/components/PluginChoicePrompt.tsx
Normal file
195
packages/cli/src/ui/components/PluginChoicePrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -431,7 +431,6 @@ export const App: React.FC = () => {
|
||||
type: 'permissionResponse',
|
||||
data: { optionId },
|
||||
});
|
||||
|
||||
setPermissionRequest(null);
|
||||
},
|
||||
[vscode],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user