Compare commits

..

13 Commits

Author SHA1 Message Date
pomelo-nwu
e615438e89 security: fix awk/sed command injection in READ_ONLY_ROOT_COMMANDS
- Add detection for awk system() calls and file operations
- Add detection for sed execute/write/read commands
- Prevent execution of arbitrary commands via awk 'BEGIN {system("...")}'
- Prevent file operations via awk '{print > "file"}' and sed 'w file'
- Add comprehensive test coverage for security fixes

Fixes security vulnerability where awk and sed side-effects were not
properly detected, allowing arbitrary command execution without user
confirmation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-25 09:13:19 +08:00
Mingholy
829ba9c431 Merge pull request #1516 from QwenLM/mingholy/fix/runtime-timeout
feat: add runtime-aware fetch options for Anthropic and OpenAI providers
2026-01-23 14:27:50 +08:00
tanzhenxin
8d0f785c28 Merge pull request #1572 from weiyuanke/patch-1
Update command usage in add.ts to reflect new name
2026-01-23 09:33:01 +08:00
tanzhenxin
6be47fe008 Merge pull request #1542 from QwenLM/vscode-ide-companion-github-action-publish
Add VSCode IDE Companion Release Workflow
2026-01-23 09:32:39 +08:00
tanzhenxin
29e71a5d7d Merge pull request #1553 from QwenLM/feature/add-trendshift-badge
docs: add Trendshift badge to README
2026-01-23 09:15:12 +08:00
yiliang114
bfe451bb4a ci(vscode-ide-companion): improve release workflow and fix yaml lint errors
- Fix yaml lint errors by properly quoting conditional expressions
- Update package version step to use correct working directory
- Modify test execution to run in the correct directory (packages/vscode-ide-companion)
- Enhance version retrieval logic to use actual package version for preview releases
- Add working directory to all relevant steps for consistency
- Simplify package version update command by removing redundant workspace flag

These changes ensure the release workflow runs correctly and follows
consistent directory structure practices.
2026-01-22 21:40:09 +08:00
yiliang114
c143c68656 Merge branch 'main' of https://github.com/QwenLM/qwen-code into vscode-ide-companion-github-action-publish 2026-01-22 21:19:35 +08:00
顾盼
011f3d2320 Merge pull request #1580 from QwenLM/feat/extension-improvements
feat(extensions): add detail command and improve extension validation
2026-01-22 20:00:55 +08:00
yuanke wei
27df0486a3 Update command usage in add.ts to reflect new name 2026-01-22 09:56:59 +08:00
pomelo-nwu
47ee9b5db8 docs: add Trendshift badge to README
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-01-20 16:17:49 +08:00
yiliang114
605e8709fb build(vscode): Add VSCode IDE Companion Publish Workflow 2026-01-19 15:04:05 +08:00
mingholy.lmh
4a0e55530b test: mock runtime fetch options in DashScope and Default OpenAI providers 2026-01-19 11:37:10 +08:00
mingholy.lmh
510d38fe3a feat: add runtime-aware fetch options for Anthropic and OpenAI providers 2026-01-16 17:18:48 +08:00
42 changed files with 902 additions and 1446 deletions

View File

@@ -0,0 +1,207 @@
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}"

View File

@@ -5,6 +5,8 @@
[![Node.js Version](https://img.shields.io/badge/node-%3E%3D20.0.0-brightgreen.svg)](https://nodejs.org/)
[![Downloads](https://img.shields.io/npm/dm/@qwen-code/qwen-code.svg)](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> |

View File

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

View File

@@ -1,8 +1,8 @@
# Qwen Code Extensions
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.
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.
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.
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,7 +21,6 @@ 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
@@ -32,30 +31,26 @@ 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 from a marketplace and choose a plugin:
Qwen Code also supports plugins from the [Claude Code Marketplace](https://claudemarketplaces.com/). Install them using the marketplace URL format:
```bash
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
qwen extensions install <claude-code-marketplace-url>:<plugin-name>
```
Claude plugins are automatically converted to Qwen Code format during installation:
@@ -65,36 +60,8 @@ 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
@@ -141,6 +108,20 @@ You can update all extensions with:
qwen extensions update --all
```
### Exploring Extension Marketplaces
You can quickly browse available extensions from different marketplaces using the `/extensions explore` command:
```bash
# Open Gemini CLI Extensions marketplace
/extensions explore Gemini
# Open Claude Code marketplace
/extensions explore ClaudeCode
```
This command opens the respective marketplace in your default browser, allowing you to discover new extensions to enhance your Qwen Code experience.
## How it works
On startup, Qwen Code looks for extensions in `<home>/.qwen/extensions`

3
package-lock.json generated
View File

@@ -3879,6 +3879,7 @@
"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": "*",
@@ -17348,7 +17349,6 @@
"@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,7 +17364,6 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",

View File

@@ -40,7 +40,6 @@
"@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",
@@ -56,7 +55,6 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ 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';
@@ -23,7 +22,6 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
null,
requestConsentNonInteractive,
),
requestChoicePlugin: requestChoicePluginNonInteractive,
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
});
await extensionManager.refreshCache();

View File

@@ -139,7 +139,7 @@ export const addCommand: CommandModule = {
describe: 'Add a server',
builder: (yargs) =>
yargs
.usage('Usage: gemini mcp add [options] <name> <commandOrUrl> [args...]')
.usage('Usage: qwen 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,7 +93,6 @@ import {
useExtensionUpdates,
useConfirmUpdateRequests,
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
@@ -177,34 +176,12 @@ 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) => {
@@ -1330,7 +1307,6 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
@@ -1393,7 +1369,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
@@ -1486,7 +1461,6 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,

View File

@@ -12,7 +12,6 @@ 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';
@@ -148,19 +147,6 @@ export const DialogManager = ({
/>
);
}
if (uiState.pluginChoiceRequests.length > 0) {
const request = uiState.pluginChoiceRequests[0];
return (
<PluginChoicePrompt
key={request.marketplaceName}
marketplaceName={request.marketplaceName}
plugins={request.plugins}
onSelect={request.onSelect}
onCancel={request.onCancel}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -113,7 +113,6 @@ 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 };
@@ -211,8 +210,10 @@ export interface ExtensionInstallMetadata {
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;
marketplaceConfig?: ClaudeMarketplaceConfig;
pluginName?: string;
marketplace?: {
marketplaceSource: string;
pluginName: string;
};
}
export const DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD = 25_000;

View File

@@ -28,6 +28,7 @@ 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;
@@ -54,6 +55,9 @@ 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,
@@ -61,6 +65,7 @@ export class AnthropicContentGenerator implements ContentGenerator {
timeout: contentGeneratorConfig.timeout,
maxRetries: contentGeneratorConfig.maxRetries,
defaultHeaders,
...runtimeOptions,
});
this.converter = new AnthropicContentConverter(

View File

@@ -19,6 +19,8 @@ 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', () => ({
@@ -32,6 +34,10 @@ vi.mock('openai', () => ({
})),
}));
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
buildRuntimeFetchOptions: vi.fn(),
}));
describe('DashScopeOpenAICompatibleProvider', () => {
let provider: DashScopeOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
@@ -39,6 +45,11 @@ describe('DashScopeOpenAICompatibleProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
// Mock ContentGeneratorConfig
mockContentGeneratorConfig = {
@@ -185,18 +196,20 @@ describe('DashScopeOpenAICompatibleProvider', () => {
it('should create OpenAI client with DashScope configuration', () => {
const client = provider.buildClient();
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(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(client).toBeDefined();
});
@@ -207,13 +220,15 @@ describe('DashScopeOpenAICompatibleProvider', () => {
provider.buildClient();
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),
});
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),
}),
);
});
});

View File

@@ -16,6 +16,7 @@ import type {
ChatCompletionContentPartWithCache,
ChatCompletionToolWithCache,
} from './types.js';
import { buildRuntimeFetchOptions } from '../../../utils/runtimeFetchOptions.js';
export class DashScopeOpenAICompatibleProvider
implements OpenAICompatibleProvider
@@ -68,12 +69,16 @@ 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 } : {}),
});
}

View File

@@ -17,6 +17,8 @@ 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', () => ({
@@ -30,6 +32,10 @@ vi.mock('openai', () => ({
})),
}));
vi.mock('../../../utils/runtimeFetchOptions.js', () => ({
buildRuntimeFetchOptions: vi.fn(),
}));
describe('DefaultOpenAICompatibleProvider', () => {
let provider: DefaultOpenAICompatibleProvider;
let mockContentGeneratorConfig: ContentGeneratorConfig;
@@ -37,6 +43,11 @@ describe('DefaultOpenAICompatibleProvider', () => {
beforeEach(() => {
vi.clearAllMocks();
const mockedBuildRuntimeFetchOptions =
buildRuntimeFetchOptions as unknown as MockedFunction<
(sdkType: 'openai') => OpenAIRuntimeFetchOptions
>;
mockedBuildRuntimeFetchOptions.mockReturnValue(undefined);
// Mock ContentGeneratorConfig
mockContentGeneratorConfig = {
@@ -112,15 +123,17 @@ describe('DefaultOpenAICompatibleProvider', () => {
it('should create OpenAI client with correct configuration', () => {
const client = provider.buildClient();
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(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(client).toBeDefined();
});
@@ -131,15 +144,17 @@ describe('DefaultOpenAICompatibleProvider', () => {
provider.buildClient();
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})`,
},
});
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})`,
},
}),
);
});
it('should include custom headers from buildHeaders', () => {

View File

@@ -4,6 +4,7 @@ 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
@@ -43,12 +44,16 @@ 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 } : {}),
});
}

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import type {
ExtensionInstallMetadata,
SkillConfig,
SubagentConfig,
ClaudeMarketplaceConfig,
} from '../index.js';
import {
Storage,
@@ -22,7 +21,6 @@ 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,
@@ -38,11 +36,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';
@@ -64,7 +62,9 @@ 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,9 +151,6 @@ export interface ExtensionManagerOptions {
config?: Config;
requestConsent?: (options?: ExtensionRequestOptions) => Promise<void>;
requestSetting?: (setting: ExtensionSetting) => Promise<string>;
requestChoicePlugin?: (
marketplace: ClaudeMarketplaceConfig,
) => Promise<string>;
}
// ============================================================================
@@ -277,9 +274,6 @@ 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();
@@ -292,8 +286,6 @@ 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;
@@ -316,14 +308,6 @@ export class ExtensionManager {
this.requestSetting = requestSetting;
}
setRequestChoicePlugin(
requestChoicePlugin: (
marketplace: ClaudeMarketplaceConfig,
) => Promise<string>,
): void {
this.requestChoicePlugin = requestChoicePlugin;
}
// ==========================================================================
// Enablement functionality (directly implemented)
// ==========================================================================
@@ -688,9 +672,9 @@ export class ExtensionManager {
pathSeparator: path.sep,
}) as unknown as ExtensionConfig;
if (!config.name) {
if (!config.name || !config.version) {
throw new Error(
`Invalid configuration in ${configFilePath}: missing "name"}`,
`Invalid configuration in ${configFilePath}: missing ${!config.name ? '"name"' : '"version"'}`,
);
}
validateName(config.name);
@@ -750,20 +734,35 @@ export class ExtensionManager {
}
let tempDir: string | undefined;
let claudePluginName: string | undefined;
if (
installMetadata.type === 'marketplace' &&
installMetadata.marketplaceConfig &&
!installMetadata.pluginName
) {
const pluginName = await this.requestChoicePlugin(
installMetadata.marketplaceConfig,
// Handle marketplace installation
if (installMetadata.type === 'marketplace') {
const marketplaceParsed = parseMarketplaceSource(
installMetadata.source,
);
installMetadata.pluginName = pluginName;
}
if (!marketplaceParsed) {
throw new Error(
`Invalid marketplace source format: ${installMetadata.source}. Expected format: marketplace-url:plugin-name`,
);
}
if (
installMetadata.type === 'marketplace' ||
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 (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
@@ -773,21 +772,11 @@ export class ExtensionManager {
installMetadata,
tempDir,
);
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
}
installMetadata.type = result.type;
installMetadata.releaseTag = result.tagName;
} catch (_error) {
await cloneFromGit(installMetadata, tempDir);
if (
installMetadata.type === 'git' ||
installMetadata.type === 'github-release'
) {
installMetadata.type = 'git';
}
installMetadata.type = 'git';
}
localSourcePath = tempDir;
} else if (
@@ -802,7 +791,7 @@ export class ExtensionManager {
try {
localSourcePath = await convertGeminiOrClaudeExtension(
localSourcePath,
installMetadata.pluginName,
claudePluginName,
);
newExtensionConfig = this.loadExtensionConfig({
extensionDir: localSourcePath,
@@ -908,7 +897,12 @@ export class ExtensionManager {
);
}
if (installMetadata.type !== 'link') {
if (
installMetadata.type === 'local' ||
installMetadata.type === 'git' ||
installMetadata.type === 'github-release' ||
installMetadata.type === 'marketplace'
) {
await copyExtension(localSourcePath, destinationPath);
}
@@ -1256,3 +1250,38 @@ 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;
}

View File

@@ -118,6 +118,32 @@ 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',

View File

@@ -53,7 +53,10 @@ export async function cloneFromGit(
): Promise<void> {
try {
const git = simpleGit(destination);
let sourceUrl = installMetadata.source;
let sourceUrl =
installMetadata.type === 'marketplace' && installMetadata.marketplace
? installMetadata.marketplace.marketplaceSource
: installMetadata.source;
const token = getGitHubToken();
if (token) {
try {
@@ -236,8 +239,12 @@ export async function downloadFromGitHubRelease(
installMetadata: ExtensionInstallMetadata,
destination: string,
): Promise<GitHubDownloadResult> {
const { source, ref } = installMetadata;
const { owner, repo } = parseGitHubRepoForReleases(source);
const { source, ref, marketplace, type } = installMetadata;
const { owner, repo } = parseGitHubRepoForReleases(
type === 'marketplace' && marketplace
? marketplace.marketplaceSource
: source,
);
try {
const releaseData = await fetchReleaseFromGithub(owner, repo, ref);

View File

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

View File

@@ -4,208 +4,75 @@
* SPDX-License-Identifier: Apache-2.0
*/
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';
import { describe, it, expect } from 'vitest';
import { parseMarketplaceSource } from './marketplace.js';
// 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',
describe('Marketplace Installation', () => {
describe('parseMarketplaceSource', () => {
it('should parse valid marketplace source with http URL', () => {
const result = parseMarketplaceSource(
'http://example.com/marketplace:my-plugin',
);
expect(result.source).toBe('https://github.com/owner/repo');
expect(result.type).toBe('git');
expect(result.pluginName).toBe('my-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.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 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',
);
});
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;
expect(result).toEqual({
marketplaceSource: 'http://example.com/marketplace',
pluginName: 'my-plugin',
});
const result = await parseInstallSource('owner/repo');
expect(result.type).toBe('marketplace');
expect(result.marketplaceConfig).toEqual(mockMarketplaceConfig);
});
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 parse valid marketplace source with https URL', () => {
const result = parseMarketplaceSource(
'https://github.com/example/marketplace:awesome-plugin',
);
expect(result).toEqual({
marketplaceSource: 'https://github.com/example/marketplace',
pluginName: 'awesome-plugin',
});
});
expect(result.type).toBe('git');
expect(result.marketplaceConfig).toBeUndefined();
it('should handle plugin names with hyphens', () => {
const result = parseMarketplaceSource(
'https://example.com:my-super-plugin',
);
expect(result).toEqual({
marketplaceSource: 'https://example.com',
pluginName: 'my-super-plugin',
});
});
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 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',
});
});
});
});

View File

@@ -4,14 +4,15 @@
* 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;
@@ -27,242 +28,34 @@ export interface MarketplaceInstallResult {
}
/**
* 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
* Parse marketplace install source string.
* Format: marketplace-url:plugin-name
*/
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;
}
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;
}
// For non-URL sources (local paths or owner/repo format)
// 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
if (
repoEndIndex === source.length &&
!urlSchemes.some((s) => source.startsWith(s))
!marketplaceSource.startsWith('http://') &&
!marketplaceSource.startsWith('https://')
) {
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 (hasPluginName) {
return {
repo: source.substring(0, repoEndIndex),
pluginName: source.substring(repoEndIndex + 1),
};
}
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 {
if (!pluginName || pluginName.length === 0) {
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;
return { marketplaceSource, pluginName };
}

View File

@@ -0,0 +1,167 @@
/**
* @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 {};
}
}
}
}
}

View File

@@ -53,4 +53,106 @@ describe('evaluateShellCommandReadOnly', () => {
const result = isShellCommandReadOnly('FOO=bar ls');
expect(result).toBe(true);
});
describe('awk command security', () => {
it('allows safe awk commands', () => {
expect(isShellCommandReadOnly("awk '{print $1}' file.txt")).toBe(true);
expect(isShellCommandReadOnly('awk \'BEGIN {print "hello"}\'')).toBe(
true,
);
expect(isShellCommandReadOnly("awk '/pattern/ {print}' file.txt")).toBe(
true,
);
});
it('rejects awk with system() calls', () => {
expect(isShellCommandReadOnly('awk \'BEGIN {system("rm -rf /")}\'')).toBe(
false,
);
expect(
isShellCommandReadOnly('awk \'{system("touch file")}\' input.txt'),
).toBe(false);
expect(isShellCommandReadOnly('awk \'BEGIN { system ( "ls" ) }\'')).toBe(
false,
);
});
it('rejects awk with file output redirection', () => {
expect(
isShellCommandReadOnly('awk \'{print > "output.txt"}\' input.txt'),
).toBe(false);
expect(
isShellCommandReadOnly('awk \'{printf "%s\\n", $0 > "file.txt"}\''),
).toBe(false);
expect(
isShellCommandReadOnly('awk \'{print >> "append.txt"}\' input.txt'),
).toBe(false);
expect(
isShellCommandReadOnly('awk \'{printf "%s" >> "file.txt"}\''),
).toBe(false);
});
it('rejects awk with command pipes', () => {
expect(isShellCommandReadOnly('awk \'{print | "sort"}\' input.txt')).toBe(
false,
);
expect(
isShellCommandReadOnly('awk \'{printf "%s\\n", $0 | "wc -l"}\''),
).toBe(false);
});
it('rejects awk with getline from commands', () => {
expect(isShellCommandReadOnly('awk \'BEGIN {getline < "date"}\'')).toBe(
false,
);
expect(isShellCommandReadOnly('awk \'BEGIN {"date" | getline}\'')).toBe(
false,
);
});
it('rejects awk with close() calls', () => {
expect(isShellCommandReadOnly('awk \'BEGIN {close("file")}\'')).toBe(
false,
);
expect(isShellCommandReadOnly("awk '{close(cmd)}' input.txt")).toBe(
false,
);
});
});
describe('sed command security', () => {
it('allows safe sed commands', () => {
expect(isShellCommandReadOnly("sed 's/foo/bar/' file.txt")).toBe(true);
expect(isShellCommandReadOnly("sed -n '1,5p' file.txt")).toBe(true);
expect(isShellCommandReadOnly("sed '/pattern/d' file.txt")).toBe(true);
});
it('rejects sed with execute command', () => {
expect(isShellCommandReadOnly("sed 's/foo/bar/e' file.txt")).toBe(false);
expect(isShellCommandReadOnly("sed 'e date' file.txt")).toBe(false);
});
it('rejects sed with write command', () => {
expect(
isShellCommandReadOnly("sed 's/foo/bar/w output.txt' file.txt"),
).toBe(false);
expect(isShellCommandReadOnly("sed 'w backup.txt' file.txt")).toBe(false);
});
it('rejects sed with read command', () => {
expect(
isShellCommandReadOnly("sed 's/foo/bar/r input.txt' file.txt"),
).toBe(false);
expect(isShellCommandReadOnly("sed 'r header.txt' file.txt")).toBe(false);
});
it('still rejects sed in-place editing', () => {
expect(isShellCommandReadOnly("sed -i 's/foo/bar/' file.txt")).toBe(
false,
);
expect(
isShellCommandReadOnly("sed --in-place 's/foo/bar/' file.txt"),
).toBe(false);
});
});
});

View File

@@ -92,6 +92,30 @@ const BLOCKED_GIT_BRANCH_FLAGS = new Set([
const BLOCKED_SED_PREFIXES = ['-i'];
// AWK side-effect patterns that can execute commands or write files
const AWK_SIDE_EFFECT_PATTERNS = [
/system\s*\(/, // system() function calls
/print\s+[^>|]*>\s*"[^"]*"/, // print > "file"
/printf\s+[^>|]*>\s*"[^"]*"/, // printf > "file"
/print\s+[^>|]*>>\s*"[^"]*"/, // print >> "file"
/printf\s+[^>|]*>>\s*"[^"]*"/, // printf >> "file"
/print\s+[^|]*\|\s*"[^"]*"/, // print | "command"
/printf\s+[^|]*\|\s*"[^"]*"/, // printf | "command"
/getline\s*<\s*"[^"]*"/, // getline < "command"
/"[^"]*"\s*\|\s*getline/, // "command" | getline
/close\s*\(/, // close() can trigger command execution
];
// SED side-effect patterns
const SED_SIDE_EFFECT_PATTERNS = [
/[^\\]e\s/, // e command (execute)
/^e\s/, // e command at start
/[^\\]w\s/, // w command (write)
/^w\s/, // w command at start
/[^\\]r\s/, // r command (read file)
/^r\s/, // r command at start
];
const ENV_ASSIGNMENT_REGEX = /^[A-Za-z_][A-Za-z0-9_]*=/;
function containsWriteRedirection(command: string): boolean {
@@ -182,6 +206,31 @@ function evaluateSedCommand(tokens: string[]): boolean {
return false;
}
}
// Check for side-effect patterns in sed script
const scriptContent = rest.join(' ');
for (const pattern of SED_SIDE_EFFECT_PATTERNS) {
if (pattern.test(scriptContent)) {
return false;
}
}
return true;
}
function evaluateAwkCommand(tokens: string[]): boolean {
const [, ...rest] = tokens;
// Join all arguments to check for awk script content
const scriptContent = rest.join(' ');
// Check for dangerous side-effect patterns
for (const pattern of AWK_SIDE_EFFECT_PATTERNS) {
if (pattern.test(scriptContent)) {
return false;
}
}
return true;
}
@@ -276,6 +325,10 @@ function evaluateShellSegment(segment: string): boolean {
return evaluateSedCommand([normalizedRoot, ...args]);
}
if (normalizedRoot === 'awk') {
return evaluateAwkCommand([normalizedRoot, ...args]);
}
if (normalizedRoot === 'git') {
return evaluateGitCommand([normalizedRoot, ...args]);
}