mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-01-24 17:56:21 +00:00
Compare commits
2 Commits
main
...
feat/exten
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e24301f8 | ||
|
|
9af9ea259d |
207
.github/workflows/release-vscode-companion.yml
vendored
207
.github/workflows/release-vscode-companion.yml
vendored
@@ -1,207 +0,0 @@
|
||||
name: 'Release VSCode IDE Companion'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'The version to release (e.g., v0.1.11). Required for manual patch releases.'
|
||||
required: false
|
||||
type: 'string'
|
||||
ref:
|
||||
description: 'The branch or ref (full git sha) to release from.'
|
||||
required: true
|
||||
type: 'string'
|
||||
default: 'main'
|
||||
dry_run:
|
||||
description: 'Run a dry-run of the release process; no branches, vsix packages or GitHub releases will be created.'
|
||||
required: true
|
||||
type: 'boolean'
|
||||
default: true
|
||||
create_preview_release:
|
||||
description: 'Auto apply the preview release tag, input version is ignored.'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
force_skip_tests:
|
||||
description: 'Select to skip the "Run Tests" step in testing. Prod releases should run tests'
|
||||
required: false
|
||||
type: 'boolean'
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: '${{ github.workflow }}'
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-vscode-companion:
|
||||
runs-on: 'ubuntu-latest'
|
||||
environment:
|
||||
name: 'production-release'
|
||||
url: '${{ github.server_url }}/${{ github.repository }}/releases/tag/vscode-companion-${{ steps.version.outputs.RELEASE_TAG }}'
|
||||
if: |-
|
||||
${{ github.repository == 'QwenLM/qwen-code' }}
|
||||
permissions:
|
||||
contents: 'read'
|
||||
issues: 'write'
|
||||
|
||||
steps:
|
||||
- name: 'Checkout'
|
||||
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
|
||||
with:
|
||||
ref: '${{ github.event.inputs.ref || github.sha }}'
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set booleans for simplified logic'
|
||||
env:
|
||||
CREATE_PREVIEW_RELEASE: '${{ github.event.inputs.create_preview_release }}'
|
||||
DRY_RUN_INPUT: '${{ github.event.inputs.dry_run }}'
|
||||
id: 'vars'
|
||||
run: |-
|
||||
is_preview="false"
|
||||
if [[ "${CREATE_PREVIEW_RELEASE}" == "true" ]]; then
|
||||
is_preview="true"
|
||||
fi
|
||||
echo "is_preview=${is_preview}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
is_dry_run="false"
|
||||
if [[ "${DRY_RUN_INPUT}" == "true" ]]; then
|
||||
is_dry_run="true"
|
||||
fi
|
||||
echo "is_dry_run=${is_dry_run}" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: 'Setup Node.js'
|
||||
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'package-lock.json'
|
||||
|
||||
- name: 'Install Dependencies'
|
||||
env:
|
||||
NPM_CONFIG_PREFER_OFFLINE: 'true'
|
||||
run: |-
|
||||
npm ci
|
||||
|
||||
- name: 'Install VSCE and OVSX'
|
||||
run: |-
|
||||
npm install -g @vscode/vsce
|
||||
npm install -g ovsx
|
||||
|
||||
- name: 'Get the version'
|
||||
id: 'version'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
# Get the base version from package.json regardless of scenario
|
||||
BASE_VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [[ "${IS_PREVIEW}" == "true" ]]; then
|
||||
# Generate preview version with timestamp based on actual package version
|
||||
TIMESTAMP=$(date +%Y%m%d%H%M%S)
|
||||
PREVIEW_VERSION="${BASE_VERSION}-preview.${TIMESTAMP}"
|
||||
RELEASE_TAG="preview.${TIMESTAMP}"
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${PREVIEW_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=preview" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# Use specified version or get from package.json
|
||||
if [[ -n "${MANUAL_VERSION}" ]]; then
|
||||
RELEASE_VERSION="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
RELEASE_TAG="${MANUAL_VERSION#v}" # Remove 'v' prefix if present
|
||||
else
|
||||
RELEASE_VERSION="${BASE_VERSION}"
|
||||
RELEASE_TAG="${BASE_VERSION}"
|
||||
fi
|
||||
|
||||
echo "RELEASE_TAG=${RELEASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "VSCODE_TAG=latest" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
env:
|
||||
IS_PREVIEW: '${{ steps.vars.outputs.is_preview }}'
|
||||
MANUAL_VERSION: '${{ inputs.version }}'
|
||||
|
||||
- name: 'Update package version (for preview releases)'
|
||||
if: '${{ steps.vars.outputs.is_preview == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
RELEASE_VERSION: '${{ steps.version.outputs.RELEASE_VERSION }}'
|
||||
run: |-
|
||||
# Update package.json with preview version
|
||||
npm version "${RELEASE_VERSION}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: 'Run Tests'
|
||||
if: |-
|
||||
${{ github.event.inputs.force_skip_tests != 'true' }}
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |
|
||||
npm run test:ci
|
||||
env:
|
||||
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
|
||||
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'
|
||||
OPENAI_MODEL: '${{ secrets.OPENAI_MODEL }}'
|
||||
|
||||
- name: 'Prepare VSCode Extension'
|
||||
run: |
|
||||
# Build and stage the extension + bundled CLI once.
|
||||
npm --workspace=qwen-code-vscode-ide-companion run prepackage
|
||||
|
||||
- name: 'Package VSIX (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
else
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
fi
|
||||
|
||||
- name: 'Upload VSIX Artifact (dry run)'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''true'' }}'
|
||||
uses: 'actions/upload-artifact@v4'
|
||||
with:
|
||||
name: 'qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
path: 'packages/qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix'
|
||||
if-no-files-found: 'error'
|
||||
|
||||
- name: 'Publish to Microsoft Marketplace'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
VSCE_PAT: '${{ secrets.VSCE_PAT }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
echo "Skipping Microsoft Marketplace for preview release"
|
||||
else
|
||||
vsce publish --pat "${VSCE_PAT}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Publish to OpenVSX'
|
||||
if: '${{ steps.vars.outputs.is_dry_run == ''false'' }}'
|
||||
working-directory: 'packages/vscode-ide-companion'
|
||||
env:
|
||||
OVSX_TOKEN: '${{ secrets.OVSX_TOKEN }}'
|
||||
VSCODE_TAG: '${{ steps.version.outputs.VSCODE_TAG }}'
|
||||
run: |-
|
||||
if [[ "${{ steps.vars.outputs.is_preview }}" == "true" ]]; then
|
||||
# For preview releases, publish with preview tag
|
||||
# First package the extension for preview
|
||||
vsce package --pre-release --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --pre-release
|
||||
else
|
||||
# Package and publish normally
|
||||
vsce package --out ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix
|
||||
ovsx publish ../qwen-code-vscode-companion-${{ steps.version.outputs.RELEASE_VERSION }}.vsix --pat "${OVSX_TOKEN}" --tag "${VSCODE_TAG}"
|
||||
fi
|
||||
|
||||
- name: 'Create Issue on Failure'
|
||||
if: |-
|
||||
${{ failure() }}
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
|
||||
run: |-
|
||||
gh issue create \
|
||||
--title "VSCode IDE Companion Release Failed for ${{ steps.version.outputs.RELEASE_VERSION }} on $(date +'%Y-%m-%d')" \
|
||||
--body "The VSCode IDE Companion release workflow failed. See the full run for details: ${DETAILS_URL}"
|
||||
@@ -5,8 +5,6 @@
|
||||
[](https://nodejs.org/)
|
||||
[](https://www.npmjs.com/package/@qwen-code/qwen-code)
|
||||
|
||||
<a href="https://trendshift.io/repositories/15287" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15287" alt="QwenLM%2Fqwen-code | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
||||
**An open-source AI agent that lives in your terminal.**
|
||||
|
||||
<a href="https://qwenlm.github.io/qwen-code-docs/zh/users/overview">中文</a> |
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('../../config/trustedFolders.js', () => ({
|
||||
vi.mock('./consent.js', () => ({
|
||||
requestConsentOrFail: vi.fn(),
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
requestChoicePluginNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getExtensionManager', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -139,7 +139,7 @@ export const addCommand: CommandModule = {
|
||||
describe: 'Add a server',
|
||||
builder: (yargs) =>
|
||||
yargs
|
||||
.usage('Usage: qwen mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
|
||||
.parserConfiguration({
|
||||
'unknown-options-as-args': true, // Pass unknown options as server args
|
||||
'populate--': true, // Populate server args after -- separator
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,7 +28,6 @@ type RawMessageStreamEvent = Anthropic.RawMessageStreamEvent;
|
||||
import { RequestTokenEstimator } from '../../utils/request-tokenizer/index.js';
|
||||
import { safeJsonParse } from '../../utils/safeJsonParse.js';
|
||||
import { AnthropicContentConverter } from './converter.js';
|
||||
import { buildRuntimeFetchOptions } from '../../utils/runtimeFetchOptions.js';
|
||||
|
||||
type StreamingBlockState = {
|
||||
type: string;
|
||||
@@ -55,9 +54,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
) {
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
const baseURL = contentGeneratorConfig.baseUrl;
|
||||
// Configure runtime options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let Anthropic SDK timeout control the request
|
||||
const runtimeOptions = buildRuntimeFetchOptions('anthropic');
|
||||
|
||||
this.client = new Anthropic({
|
||||
apiKey: contentGeneratorConfig.apiKey,
|
||||
@@ -65,7 +61,6 @@ export class AnthropicContentGenerator implements ContentGenerator {
|
||||
timeout: contentGeneratorConfig.timeout,
|
||||
maxRetries: contentGeneratorConfig.maxRetries,
|
||||
defaultHeaders,
|
||||
...runtimeOptions,
|
||||
});
|
||||
|
||||
this.converter = new AnthropicContentConverter(
|
||||
|
||||
@@ -19,8 +19,6 @@ import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { AuthType } from '../../contentGenerator.js';
|
||||
import type { ChatCompletionToolWithCache } from './types.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock('openai', () => ({
|
||||
@@ -34,10 +32,6 @@ vi.mock('openai', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
|
||||
buildRuntimeFetchOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
let provider: DashScopeOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
@@ -45,11 +39,6 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
@@ -196,20 +185,18 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
it('should create OpenAI client with DashScope configuration', () => {
|
||||
const client = provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-CacheControl': 'enable',
|
||||
'X-DashScope-UserAgent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
'X-DashScope-AuthType': AuthType.QWEN_OAUTH,
|
||||
},
|
||||
});
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
@@ -220,15 +207,13 @@ describe('DashScopeOpenAICompatibleProvider', () => {
|
||||
|
||||
provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: expect.any(Object),
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: expect.any(Object),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
ChatCompletionContentPartWithCache,
|
||||
ChatCompletionToolWithCache,
|
||||
} from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
export class DashScopeOpenAICompatibleProvider
|
||||
implements OpenAICompatibleProvider
|
||||
@@ -69,16 +68,12 @@ export class DashScopeOpenAICompatibleProvider
|
||||
maxRetries = DEFAULT_MAX_RETRIES,
|
||||
} = this.contentGeneratorConfig;
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
// Configure fetch options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
|
||||
const fetchOptions = buildRuntimeFetchOptions('openai');
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
timeout,
|
||||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(fetchOptions ? { fetchOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,8 +17,6 @@ import { DefaultOpenAICompatibleProvider } from './default.js';
|
||||
import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
import type { OpenAIRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
// Mock OpenAI
|
||||
vi.mock('openai', () => ({
|
||||
@@ -32,10 +30,6 @@ vi.mock('openai', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
|
||||
buildRuntimeFetchOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('DefaultOpenAICompatibleProvider', () => {
|
||||
let provider: DefaultOpenAICompatibleProvider;
|
||||
let mockContentGeneratorConfig: ContentGeneratorConfig;
|
||||
@@ -43,11 +37,6 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const mockedBuildRuntimeFetchOptions =
|
||||
buildRuntimeFetchOptions as unknown as MockedFunction<
|
||||
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
|
||||
>;
|
||||
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
|
||||
|
||||
// Mock ContentGeneratorConfig
|
||||
mockContentGeneratorConfig = {
|
||||
@@ -123,17 +112,15 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
it('should create OpenAI client with correct configuration', () => {
|
||||
const client = provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: 60000,
|
||||
maxRetries: 2,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(client).toBeDefined();
|
||||
});
|
||||
@@ -144,17 +131,15 @@ describe('DefaultOpenAICompatibleProvider', () => {
|
||||
|
||||
provider.buildClient();
|
||||
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(OpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://api.openai.com/v1',
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxRetries: DEFAULT_MAX_RETRIES,
|
||||
defaultHeaders: {
|
||||
'User-Agent': `QwenCode/1.0.0 (${process.platform}; ${process.arch})`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should include custom headers from buildHeaders', () => {
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { Config } from '../../../config/config.js';
|
||||
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
|
||||
import { DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES } from '../constants.js';
|
||||
import type { OpenAICompatibleProvider } from './types.js';
|
||||
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
|
||||
|
||||
/**
|
||||
* Default provider for standard OpenAI-compatible APIs
|
||||
@@ -44,16 +43,12 @@ export class DefaultOpenAICompatibleProvider
|
||||
maxRetries = DEFAULT_MAX_RETRIES,
|
||||
} = this.contentGeneratorConfig;
|
||||
const defaultHeaders = this.buildHeaders();
|
||||
// Configure fetch options to ensure user-configured timeout works as expected
|
||||
// bodyTimeout is always disabled (0) to let OpenAI SDK timeout control the request
|
||||
const fetchOptions = buildRuntimeFetchOptions('openai');
|
||||
return new OpenAI({
|
||||
apiKey,
|
||||
baseURL: baseUrl,
|
||||
timeout,
|
||||
maxRetries,
|
||||
defaultHeaders,
|
||||
...(fetchOptions ? { fetchOptions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -780,46 +779,5 @@ describe('extension tests', () => {
|
||||
expect(id).toBe(hashValue('https://github.com/owner/repo'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInstallSource', () => {
|
||||
it('should parse HTTPS URL as git type', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'https://github.com/owner/repo',
|
||||
);
|
||||
expect(result.type).toBe('git');
|
||||
expect(result.source).toBe('https://github.com/owner/repo');
|
||||
});
|
||||
|
||||
it('should parse HTTP URL as git type', async () => {
|
||||
const result = await parseInstallSource('http://example.com/repo');
|
||||
expect(result.type).toBe('git');
|
||||
});
|
||||
|
||||
it('should parse git@ URL as git type', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'git@github.com:owner/repo.git',
|
||||
);
|
||||
expect(result.type).toBe('git');
|
||||
});
|
||||
|
||||
it('should parse sso:// URL as git type', async () => {
|
||||
const result = await parseInstallSource('sso://some/path');
|
||||
expect(result.type).toBe('git');
|
||||
});
|
||||
|
||||
it('should parse marketplace URL correctly', async () => {
|
||||
const result = await parseInstallSource(
|
||||
'https://example.com/marketplace:plugin-name',
|
||||
);
|
||||
expect(result.type).toBe('marketplace');
|
||||
expect(result.marketplace?.pluginName).toBe('plugin-name');
|
||||
});
|
||||
|
||||
it('should throw for non-existent local path', async () => {
|
||||
await expect(
|
||||
parseInstallSource('/nonexistent/path/to/extension'),
|
||||
).rejects.toThrow('Install source not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1250,38 +1256,3 @@ export function validateName(name: string) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -118,32 +118,6 @@ describe('git extension helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use marketplace source for marketplace type extensions', async () => {
|
||||
const installMetadata = {
|
||||
source: 'marketplace:my-plugin',
|
||||
type: 'marketplace' as const,
|
||||
marketplace: {
|
||||
pluginName: 'my-plugin',
|
||||
marketplaceSource: 'https://github.com/marketplace/my-plugin',
|
||||
},
|
||||
};
|
||||
const destination = '/dest';
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{
|
||||
name: 'origin',
|
||||
refs: { fetch: 'https://github.com/marketplace/my-plugin' },
|
||||
},
|
||||
]);
|
||||
|
||||
await cloneFromGit(installMetadata, destination);
|
||||
|
||||
expect(mockGit.clone).toHaveBeenCalledWith(
|
||||
'https://github.com/marketplace/my-plugin',
|
||||
'./',
|
||||
['--depth', '1'],
|
||||
);
|
||||
});
|
||||
|
||||
it('should use source for marketplace type without marketplace metadata', async () => {
|
||||
const installMetadata = {
|
||||
source: 'http://fallback-repo.com',
|
||||
|
||||
@@ -53,10 +53,7 @@ export async function cloneFromGit(
|
||||
): Promise<void> {
|
||||
try {
|
||||
const git = simpleGit(destination);
|
||||
let sourceUrl =
|
||||
installMetadata.type === 'marketplace' && installMetadata.marketplace
|
||||
? installMetadata.marketplace.marketplaceSource
|
||||
: installMetadata.source;
|
||||
let sourceUrl = installMetadata.source;
|
||||
const token = getGitHubToken();
|
||||
if (token) {
|
||||
try {
|
||||
@@ -239,12 +236,8 @@ export async function downloadFromGitHubRelease(
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<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,167 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { EnvHttpProxyAgent } from 'undici';
|
||||
|
||||
/**
|
||||
* JavaScript runtime type
|
||||
*/
|
||||
export type Runtime = 'node' | 'bun' | 'unknown';
|
||||
|
||||
/**
|
||||
* Detect the current JavaScript runtime
|
||||
*/
|
||||
export function detectRuntime(): Runtime {
|
||||
if (typeof process !== 'undefined' && process.versions?.['bun']) {
|
||||
return 'bun';
|
||||
}
|
||||
if (typeof process !== 'undefined' && process.versions?.node) {
|
||||
return 'node';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime fetch options for OpenAI SDK
|
||||
*/
|
||||
export type OpenAIRuntimeFetchOptions =
|
||||
| {
|
||||
dispatcher?: EnvHttpProxyAgent;
|
||||
timeout?: false;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Runtime fetch options for Anthropic SDK
|
||||
*/
|
||||
export type AnthropicRuntimeFetchOptions = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
httpAgent?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fetch?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* SDK type identifier
|
||||
*/
|
||||
export type SDKType = 'openai' | 'anthropic';
|
||||
|
||||
/**
|
||||
* Build runtime-specific fetch options for OpenAI SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: 'openai',
|
||||
): OpenAIRuntimeFetchOptions;
|
||||
/**
|
||||
* Build runtime-specific fetch options for Anthropic SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: 'anthropic',
|
||||
): AnthropicRuntimeFetchOptions;
|
||||
/**
|
||||
* Build runtime-specific fetch options based on the detected runtime and SDK type
|
||||
* This function applies runtime-specific configurations to handle timeout differences
|
||||
* across Node.js and Bun, ensuring user-configured timeout works as expected.
|
||||
*
|
||||
* @param sdkType - The SDK type ('openai' or 'anthropic') to determine return type
|
||||
* @returns Runtime-specific options compatible with the specified SDK
|
||||
*/
|
||||
export function buildRuntimeFetchOptions(
|
||||
sdkType: SDKType,
|
||||
): OpenAIRuntimeFetchOptions | AnthropicRuntimeFetchOptions {
|
||||
const runtime = detectRuntime();
|
||||
|
||||
// Always disable bodyTimeout (set to 0) to let SDK's timeout parameter
|
||||
// control the total request time. bodyTimeout only monitors intervals between
|
||||
// data chunks, not the total request time, so we disable it to ensure user-configured
|
||||
// timeout works as expected for both streaming and non-streaming requests.
|
||||
|
||||
switch (runtime) {
|
||||
case 'bun': {
|
||||
if (sdkType === 'openai') {
|
||||
// Bun: Disable built-in 300s timeout to let OpenAI SDK timeout control
|
||||
// This ensures user-configured timeout works as expected without interference
|
||||
return {
|
||||
timeout: false,
|
||||
};
|
||||
} else {
|
||||
// Bun: Use custom fetch to disable built-in 300s timeout
|
||||
// This allows Anthropic SDK timeout to control the request
|
||||
// Note: Bun's fetch automatically uses proxy settings from environment variables
|
||||
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY), so proxy behavior is preserved
|
||||
const bunFetch: typeof fetch = async (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
const bunFetchOptions: RequestInit = {
|
||||
...init,
|
||||
// @ts-expect-error - Bun-specific timeout option
|
||||
timeout: false,
|
||||
};
|
||||
return fetch(input, bunFetchOptions);
|
||||
};
|
||||
return {
|
||||
fetch: bunFetch,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'node': {
|
||||
// Node.js: Use EnvHttpProxyAgent to configure proxy and disable bodyTimeout
|
||||
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
|
||||
// (HTTP_PROXY, HTTPS_PROXY, NO_PROXY, etc.) to preserve proxy functionality
|
||||
// bodyTimeout is always 0 (disabled) to let SDK timeout control the request
|
||||
try {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
bodyTimeout: 0, // Disable to let SDK timeout control total request time
|
||||
});
|
||||
|
||||
if (sdkType === 'openai') {
|
||||
return {
|
||||
dispatcher: agent,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
httpAgent: agent,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// If undici is not available, return appropriate default
|
||||
if (sdkType === 'openai') {
|
||||
return undefined;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
// Unknown runtime: Try to use EnvHttpProxyAgent if available
|
||||
// EnvHttpProxyAgent automatically reads proxy settings from environment variables
|
||||
try {
|
||||
const agent = new EnvHttpProxyAgent({
|
||||
bodyTimeout: 0, // Disable to let SDK timeout control total request time
|
||||
});
|
||||
|
||||
if (sdkType === 'openai') {
|
||||
return {
|
||||
dispatcher: agent,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
httpAgent: agent,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
if (sdkType === 'openai') {
|
||||
return undefined;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user