Compare commits

..

12 Commits

Author SHA1 Message Date
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
LaZzyMan
674bb6386e feat(extensions): add detail command and improve extension validation
- Add /extensions detail command to show extension details
- Allow underscores and dots in extension names
- Fix contextFileName empty array handling to use default QWEN.md
- Fix marketplace extension clone to use correct source URL
- Add inline parameter to extensionToOutputString
- Add comprehensive tests for all changes
2026-01-22 19:37:01 +08:00
tanzhenxin
2aa681f610 Merge pull request #1578 from QwenLM/fix/pkg-dependence
fix prompts denpendence
2026-01-22 16:04:59 +08:00
LaZzyMan
3b6849be94 fix prompts denpendence 2026-01-22 15:48:54 +08:00
tanzhenxin
a7e55ccf43 Merge pull request #1576 from QwenLM/fix/pkg-dependence
fix github pkg dependence
2026-01-22 15:30:29 +08:00
LaZzyMan
c0d78a8f3c fix github pkg dependence 2026-01-22 15:14:55 +08:00
tanzhenxin
64eea4889d Merge pull request #1574 from QwenLM/fix/pkg-dependence
fix dependences of core pkg
2026-01-22 14:26:02 +08:00
LaZzyMan
aa80e738fb fix dependences of core pkg 2026-01-22 14:09:08 +08:00
tanzhenxin
06b64b07e7 Merge pull request #1534 from QwenLM/feat/extension
Feat/extension
2026-01-22 12:29:01 +08:00
yiliang114
605e8709fb build(vscode): Add VSCode IDE Companion Publish Workflow 2026-01-19 15:04:05 +08:00
14 changed files with 574 additions and 69 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}"

11
package-lock.json generated
View File

@@ -17355,7 +17355,6 @@
"comment-json": "^4.2.5",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"extract-zip": "^2.0.1",
"fzf": "^0.5.2",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
@@ -17365,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",
@@ -17374,7 +17372,6 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
@@ -17394,12 +17391,10 @@
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/node": "^20.11.24",
"@types/prompts": "^2.4.9",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/tar": "^6.1.13",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
@@ -17987,6 +17982,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.36.1",
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.25.1",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0",
@@ -18006,6 +18002,7 @@
"chokidar": "^4.0.3",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"extract-zip": "^2.0.1",
"fast-levenshtein": "^2.0.6",
"fast-uri": "^3.0.6",
"fdir": "^6.4.6",
@@ -18022,9 +18019,11 @@
"open": "^10.1.2",
"openai": "5.11.0",
"picomatch": "^4.0.1",
"prompts": "^2.4.2",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tar": "^7.5.2",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
@@ -18036,6 +18035,8 @@
"@types/fast-levenshtein": "^0.0.4",
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/prompts": "^2.4.9",
"@types/tar": "^6.1.13",
"@types/ws": "^8.5.10",
"msw": "^2.3.4",
"typescript": "^5.3.3",

View File

@@ -46,8 +46,6 @@
"comment-json": "^4.2.5",
"diff": "^7.0.0",
"dotenv": "^17.1.0",
"prompts": "^2.4.2",
"extract-zip": "^2.0.1",
"fzf": "^0.5.2",
"glob": "^10.5.0",
"highlight.js": "^11.11.1",
@@ -65,7 +63,6 @@
"string-width": "^7.1.0",
"strip-ansi": "^7.1.0",
"strip-json-comments": "^3.1.1",
"tar": "^7.5.2",
"undici": "^6.22.0",
"update-notifier": "^7.3.1",
"wrap-ansi": "9.0.2",
@@ -81,13 +78,11 @@
"@types/command-exists": "^1.2.3",
"@types/diff": "^7.0.2",
"@types/dotenv": "^6.1.1",
"@types/prompts": "^2.4.9",
"@types/node": "^20.11.24",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/tar": "^6.1.13",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",

View File

@@ -23,6 +23,7 @@ vi.mock('./utils.js', () => ({
getLoadedExtensions: mockGetLoadedExtensions,
toOutputString: mockToOutputString,
}),
extensionToOutputString: mockToOutputString,
}));
vi.mock('../../utils/errors.js', () => ({

View File

@@ -6,7 +6,7 @@
import type { CommandModule } from 'yargs';
import { getErrorMessage } from '../../utils/errors.js';
import { getExtensionManager } from './utils.js';
import { extensionToOutputString, getExtensionManager } from './utils.js';
import { t } from '../../i18n/index.js';
export async function handleList() {
@@ -21,7 +21,7 @@ export async function handleList() {
console.log(
extensions
.map((extension, _): string =>
extensionManager.toOutputString(extension, process.cwd()),
extensionToOutputString(extension, extensionManager, process.cwd()),
)
.join('\n\n'),
);

View File

@@ -5,7 +5,8 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getExtensionManager } from './utils.js';
import { getExtensionManager, extensionToOutputString } from './utils.js';
import type { Extension, ExtensionManager } from '@qwen-code/qwen-code-core';
const mockRefreshCache = vi.fn();
const mockExtensionManagerInstance = {
@@ -64,3 +65,70 @@ describe('getExtensionManager', () => {
);
});
});
describe('extensionToOutputString', () => {
const mockIsEnabled = vi.fn();
const mockExtensionManager = {
isEnabled: mockIsEnabled,
} as unknown as ExtensionManager;
const createMockExtension = (overrides = {}): Extension => ({
id: 'test-ext-id',
name: 'test-extension',
version: '1.0.0',
isActive: true,
path: '/path/to/extension',
contextFiles: [],
config: { name: 'test-extension', version: '1.0.0' },
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockIsEnabled.mockReturnValue(true);
});
it('should include status icon when inline is false', () => {
const extension = createMockExtension();
const result = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
false,
);
// Should contain either ✓ or ✗ (with ANSI color codes)
expect(result).toMatch(/test-extension/);
expect(result).toContain('(1.0.0)');
});
it('should exclude status icon when inline is true', () => {
const extension = createMockExtension();
const result = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
true,
);
// Should start with extension name (after stripping potential whitespace)
expect(result.trim()).toMatch(/^test-extension/);
});
it('should default inline to false', () => {
const extension = createMockExtension();
const resultWithoutInline = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
);
const resultWithInlineFalse = extensionToOutputString(
extension,
mockExtensionManager,
'/workspace',
false,
);
expect(resultWithoutInline).toEqual(resultWithInlineFalse);
});
});

View File

@@ -4,13 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { ExtensionManager } from '@qwen-code/qwen-code-core';
import { ExtensionManager, type Extension } from '@qwen-code/qwen-code-core';
import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
} from './consent.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import * as os from 'node:os';
import chalk from 'chalk';
export async function getExtensionManager(): Promise<ExtensionManager> {
const workspaceDir = process.cwd();
@@ -25,3 +27,54 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
await extensionManager.refreshCache();
return extensionManager;
}
export function extensionToOutputString(
extension: Extension,
extensionManager: ExtensionManager,
workspaceDir: string,
inline = false,
): string {
const cwd = workspaceDir;
const userEnabled = extensionManager.isEnabled(
extension.config.name,
os.homedir(),
);
const workspaceEnabled = extensionManager.isEnabled(
extension.config.name,
cwd,
);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
if (extension.installMetadata.ref) {
output += `\n Ref: ${extension.installMetadata.ref}`;
}
if (extension.installMetadata.releaseTag) {
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
}
}
output += `\n Enabled (User): ${userEnabled}`;
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.commands && extension.commands.length > 0) {
output += `\n Commands:`;
extension.commands.forEach((command) => {
output += `\n /${command}`;
});
}
if (extension.config.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.config.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
return output;
}

View File

@@ -777,4 +777,87 @@ describe('extensionsCommand', () => {
);
});
});
describe('detail', () => {
const detailAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'detail',
)?.action;
if (!detailAction) {
throw new Error('Detail action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions detail',
name: 'detail',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if no name is provided', async () => {
await detailAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions detail <extension-name>',
},
expect.any(Number),
);
});
it('should show error if extension not found', async () => {
mockGetExtensions.mockReturnValue([]);
await detailAction(mockContext, 'nonexistent-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension "nonexistent-extension" not found.',
},
expect.any(Number),
);
});
it('should show extension details when found', async () => {
const extension: Extension = {
id: 'test-ext',
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/test-ext',
contextFiles: [],
config: { name: 'test-ext', version: '1.0.0' },
};
mockGetExtensions.mockReturnValue([extension]);
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
await detailAction(mockContext, 'test-ext');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('test-ext'),
},
expect.any(Number),
);
});
});
});

View File

@@ -20,6 +20,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { SettingScope } from '../../config/settings.js';
import open from 'open';
import { extensionToOutputString } from '../../commands/extensions/utils.js';
const EXTENSION_EXPLORE_URL = {
Gemini: 'https://geminicli.com/extensions/',
@@ -475,6 +476,53 @@ async function enableAction(context: CommandContext, args: string) {
}
}
async function detailAction(context: CommandContext, args: string) {
const extensionManager = context.services.config?.getExtensionManager();
if (!(extensionManager instanceof ExtensionManager)) {
console.error(
`Cannot ${context.invocation?.name} extensions in this environment`,
);
return;
}
const name = args.trim();
if (!name) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Usage: /extensions detail <extension-name>'),
},
Date.now(),
);
return;
}
const extensions = context.services.config!.getExtensions();
const extension = extensions.find((extension) => extension.name === name);
if (!extension) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Extension "{{name}}" not found.', { name }),
},
Date.now(),
);
return;
}
context.ui.addItem(
{
type: MessageType.INFO,
text: extensionToOutputString(
extension,
extensionManager,
process.cwd(),
true,
),
},
Date.now(),
);
}
export async function completeExtensions(
context: CommandContext,
partialArg: string,
@@ -495,7 +543,10 @@ export async function completeExtensions(
name.startsWith(partialArg),
);
if (context.invocation?.name !== 'uninstall') {
if (
context.invocation?.name !== 'uninstall' &&
context.invocation?.name !== 'detail'
) {
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
suggestions.unshift('--all');
}
@@ -594,6 +645,16 @@ const uninstallCommand: SlashCommand = {
completion: completeExtensions,
};
const detailCommand: SlashCommand = {
name: 'detail',
get description() {
return t('Get detail of an extension');
},
kind: CommandKind.BUILT_IN,
action: detailAction,
completion: completeExtensions,
};
export const extensionsCommand: SlashCommand = {
name: 'extensions',
get description() {
@@ -608,6 +669,7 @@ export const extensionsCommand: SlashCommand = {
installCommand,
uninstallCommand,
exploreExtensionsCommand,
detailCommand,
],
action: (context, args) =>
// Default to list if no subcommand is provided

View File

@@ -37,6 +37,7 @@
"@opentelemetry/sdk-node": "^0.203.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
"@iarna/toml": "^2.2.5",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"async-mutex": "^0.5.0",
@@ -59,10 +60,13 @@
"mnemonist": "^0.40.3",
"open": "^10.1.2",
"openai": "5.11.0",
"prompts": "^2.4.2",
"picomatch": "^4.0.1",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"strip-ansi": "^7.1.0",
"tar": "^7.5.2",
"extract-zip": "^2.0.1",
"undici": "^6.22.0",
"uuid": "^9.0.1",
"ws": "^8.18.0"
@@ -84,6 +88,8 @@
"@types/minimatch": "^5.1.2",
"@types/picomatch": "^4.0.1",
"@types/ws": "^8.5.10",
"@types/tar": "^6.1.13",
"@types/prompts": "^2.4.9",
"msw": "^2.3.4",
"typescript": "^5.3.3",
"vitest": "^3.1.1"

View File

@@ -218,6 +218,30 @@ describe('extension tests', () => {
]);
});
it('should use default QWEN.md when contextFileName is empty array', async () => {
const extDir = path.join(userExtensionsDir, 'ext-empty-context');
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({
name: 'ext-empty-context',
version: '1.0.0',
contextFileName: [],
}),
);
fs.writeFileSync(path.join(extDir, 'QWEN.md'), 'context content');
const manager = createExtensionManager();
await manager.refreshCache();
const extensions = manager.getLoadedExtensions();
expect(extensions).toHaveLength(1);
const ext = extensions.find((e) => e.config.name === 'ext-empty-context');
expect(ext?.contextFiles).toEqual([
path.join(userExtensionsDir, 'ext-empty-context', 'QWEN.md'),
]);
});
it('should skip extensions with invalid JSON and log a warning', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
@@ -694,13 +718,14 @@ describe('extension tests', () => {
expect(() => validateName('UPPERCASE')).not.toThrow();
});
it('should accept names with underscores and dots', () => {
expect(() => validateName('my_extension')).not.toThrow();
expect(() => validateName('my.extension')).not.toThrow();
expect(() => validateName('my_ext.v1')).not.toThrow();
expect(() => validateName('ext_1.2.3')).not.toThrow();
});
it('should reject names with invalid characters', () => {
expect(() => validateName('my_extension')).toThrow(
'Invalid extension name',
);
expect(() => validateName('my.extension')).toThrow(
'Invalid extension name',
);
expect(() => validateName('my extension')).toThrow(
'Invalid extension name',
);

View File

@@ -36,7 +36,6 @@ import {
} from './github.js';
import type { LoadExtensionContext } from './variableSchema.js';
import { Override, type AllExtensionsEnablementConfig } from './override.js';
import chalk from 'chalk';
import { parseMarketplaceSource } from './marketplace.js';
import {
isGeminiExtensionConfig,
@@ -191,7 +190,7 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig {
}
function getContextFileNames(config: ExtensionConfig): string[] {
if (!config.contextFileName) {
if (!config.contextFileName || config.contextFileName.length === 0) {
return ['QWEN.md'];
} else if (!Array.isArray(config.contextFileName)) {
return [config.contextFileName];
@@ -1056,49 +1055,6 @@ export class ExtensionManager {
);
}
/**
* Converts an extension to output string.
*/
toOutputString(extension: Extension, workspaceDir?: string): string {
const cwd = workspaceDir ?? this.workspaceDir;
const userEnabled = this.isEnabled(extension.config.name, os.homedir());
const workspaceEnabled = this.isEnabled(extension.config.name, cwd);
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
if (extension.installMetadata.ref) {
output += `\n Ref: ${extension.installMetadata.ref}`;
}
if (extension.installMetadata.releaseTag) {
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
}
}
output += `\n Enabled (User): ${userEnabled}`;
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.commands && extension.commands.length > 0) {
output += `\n Commands:`;
extension.commands.forEach((command) => {
output += `\n /${command}`;
});
}
if (extension.config.mcpServers) {
output += `\n MCP servers:`;
Object.keys(extension.config.mcpServers).forEach((key) => {
output += `\n ${key}`;
});
}
return output;
}
async performWorkspaceExtensionMigration(
extensions: Extension[],
requestConsent: (options?: ExtensionRequestOptions) => Promise<void>,
@@ -1288,9 +1244,9 @@ export function hashValue(value: string): string {
}
export function validateName(name: string) {
if (!/^[a-zA-Z0-9-]+$/.test(name)) {
if (!/^[a-zA-Z0-9-_.]+$/.test(name)) {
throw new Error(
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), and dashes (-) are allowed.`,
`Invalid extension name: "${name}". Only letters (a-z, A-Z), numbers (0-9), underscores (_), dots (.), and dashes (-) are allowed.`,
);
}
}

View File

@@ -117,6 +117,51 @@ describe('git extension helpers', () => {
'Failed to clone Git repository from http://my-repo.com',
);
});
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',
type: 'marketplace' as const,
};
const destination = '/dest';
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'http://fallback-repo.com' } },
]);
await cloneFromGit(installMetadata, destination);
expect(mockGit.clone).toHaveBeenCalledWith(
'http://fallback-repo.com',
'./',
['--depth', '1'],
);
});
});
describe('checkForExtensionUpdate', () => {

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 {