Skip to content

Commit 2a93bf1

Browse files
feat(extensions): support archive install sources
1 parent 0c5a1c1 commit 2a93bf1

13 files changed

Lines changed: 1393 additions & 140 deletions

File tree

docs/users/extension/introduction.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ We offer a suite of extension management tools using both `qwen extensions` CLI
1212

1313
You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application.
1414

15-
| Command | Description |
16-
| ------------------------------------- | ---------------------------------------------------------------------------- |
17-
| `/extensions` or `/extensions manage` | Manage all installed extensions |
18-
| `/extensions install <source>` | Install an extension from a git URL, local path, npm package, or marketplace |
19-
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
15+
| Command | Description |
16+
| ------------------------------------- | ---------------------------------------------------------------------------------------------------- |
17+
| `/extensions` or `/extensions manage` | Manage all installed extensions |
18+
| `/extensions install <source>` | Install an extension from a git URL, local path or archive, archive URL, npm package, or marketplace |
19+
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |
2020

2121
### CLI Extension Management
2222

@@ -131,8 +131,26 @@ This will install the github mcp server extension.
131131
qwen extensions install /path/to/your/extension
132132
```
133133

134+
Local `.zip` and `.tar.gz` archives are also supported:
135+
136+
```bash
137+
qwen extensions install /path/to/your/extension.zip
138+
qwen extensions install /path/to/your/extension.tar.gz
139+
```
140+
141+
The archive must contain a complete extension at its root, or a single top-level directory containing the extension.
142+
134143
Note that we create a copy of the installed extension, so you will need to run `qwen extensions update` to pull in changes from both locally-defined extensions and those on GitHub.
135144

145+
#### From Archive URL
146+
147+
```bash
148+
qwen extensions install https://example.com/your/extension.zip
149+
qwen extensions install https://example.com/your/extension.tar.gz
150+
```
151+
152+
Archive URLs can be updated later as long as the URL continues to point at a newer archive for the same extension.
153+
136154
### Uninstalling an extension
137155

138156
To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example:
@@ -155,7 +173,7 @@ This is useful if you have an extension disabled at the top-level and only enabl
155173

156174
### Updating an extension
157175

158-
For extensions installed from a local path, a git repository, or an npm registry, you can explicitly update to the latest version with `qwen extensions update extension-name`. For npm extensions installed without a version pin (e.g. `@scope/pkg`), updates check the `latest` dist-tag. For those installed with a specific dist-tag (e.g. `@scope/pkg@beta`), updates track that tag. Extensions pinned to an exact version (e.g. `@scope/pkg@1.2.0`) are always considered up-to-date.
176+
For extensions installed from a local path or archive, an archive URL, a git repository, or an npm registry, you can explicitly update to the latest version with `qwen extensions update extension-name`. For npm extensions installed without a version pin (e.g. `@scope/pkg`), updates check the `latest` dist-tag. For those installed with a specific dist-tag (e.g. `@scope/pkg@beta`), updates track that tag. Extensions pinned to an exact version (e.g. `@scope/pkg@1.2.0`) are always considered up-to-date.
159177

160178
You can update all extensions with:
161179

packages/acp-bridge/src/status.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -857,7 +857,8 @@ export type ServeExtensionInstallType =
857857
| 'local'
858858
| 'link'
859859
| 'github-release'
860-
| 'npm';
860+
| 'npm'
861+
| 'archive-url';
861862

862863
export type ServeExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini';
863864

packages/cli/src/commands/extensions/install.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,61 @@ describe('handleInstall', () => {
203203
processSpy.mockRestore();
204204
});
205205

206+
it('should install an extension from an archive URL', async () => {
207+
const processSpy = vi
208+
.spyOn(process, 'exit')
209+
.mockImplementation(() => undefined as never);
210+
211+
mockParseInstallSource.mockResolvedValue({
212+
type: 'archive-url',
213+
source: 'https://example.com/extension.zip',
214+
});
215+
mockInstallExtension.mockResolvedValue({ name: 'archive-extension' });
216+
217+
await handleInstall({
218+
source: 'https://example.com/extension.zip',
219+
autoUpdate: true,
220+
});
221+
222+
expect(mockInstallExtension).toHaveBeenCalledWith(
223+
expect.objectContaining({
224+
source: 'https://example.com/extension.zip',
225+
type: 'archive-url',
226+
autoUpdate: true,
227+
}),
228+
expect.any(Function),
229+
);
230+
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
231+
'Extension "archive-extension" installed successfully and enabled.',
232+
);
233+
234+
processSpy.mockRestore();
235+
});
236+
237+
it('should reject --ref for archive URL extensions', async () => {
238+
const processSpy = vi
239+
.spyOn(process, 'exit')
240+
.mockImplementation(() => undefined as never);
241+
242+
mockParseInstallSource.mockResolvedValue({
243+
type: 'archive-url',
244+
source: 'https://example.com/extension.zip',
245+
});
246+
247+
await handleInstall({
248+
source: 'https://example.com/extension.zip',
249+
ref: 'v1.0.0',
250+
});
251+
252+
expect(mockWriteStderrLine).toHaveBeenCalledWith(
253+
'--ref is not applicable for archive URL extensions.',
254+
);
255+
expect(mockInstallExtension).not.toHaveBeenCalled();
256+
expect(processSpy).toHaveBeenCalledWith(1);
257+
258+
processSpy.mockRestore();
259+
});
260+
206261
it('should throw an error if install extension fails', async () => {
207262
const processSpy = vi
208263
.spyOn(process, 'exit')
@@ -225,4 +280,29 @@ describe('handleInstall', () => {
225280

226281
processSpy.mockRestore();
227282
});
283+
284+
it('should print archive validation errors from the extension manager', async () => {
285+
const processSpy = vi
286+
.spyOn(process, 'exit')
287+
.mockImplementation(() => undefined as never);
288+
289+
mockParseInstallSource.mockResolvedValue({
290+
type: 'git',
291+
source: 'owner/repo',
292+
});
293+
mockInstallExtension.mockRejectedValue(
294+
new Error(
295+
'Extension archive is missing qwen-extension.json. The archive must contain a complete extension at its root, or a single top-level extension directory.',
296+
),
297+
);
298+
299+
await handleInstall({ source: 'owner/repo' });
300+
301+
expect(mockWriteStderrLine).toHaveBeenCalledWith(
302+
'Extension archive is missing qwen-extension.json. The archive must contain a complete extension at its root, or a single top-level extension directory.',
303+
);
304+
expect(processSpy).toHaveBeenCalledWith(1);
305+
306+
processSpy.mockRestore();
307+
});
228308
});

packages/cli/src/commands/extensions/install.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ export async function handleInstall(args: InstallArgs) {
3737
if (
3838
installMetadata.type !== 'git' &&
3939
installMetadata.type !== 'github-release' &&
40-
installMetadata.type !== 'npm'
40+
installMetadata.type !== 'npm' &&
41+
installMetadata.type !== 'archive-url'
4142
) {
4243
if (args.ref || args.autoUpdate) {
4344
throw new Error(
@@ -48,10 +49,16 @@ export async function handleInstall(args: InstallArgs) {
4849
}
4950
}
5051

51-
if (installMetadata.type === 'npm' && args.ref) {
52+
if (
53+
(installMetadata.type === 'npm' ||
54+
installMetadata.type === 'archive-url') &&
55+
args.ref
56+
) {
5257
throw new Error(
5358
t(
54-
'--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).',
59+
installMetadata.type === 'npm'
60+
? '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).'
61+
: '--ref is not applicable for archive URL extensions.',
5562
),
5663
);
5764
}
@@ -101,13 +108,13 @@ export async function handleInstall(args: InstallArgs) {
101108
export const installCommand: CommandModule = {
102109
command: 'install <source>',
103110
describe: t(
104-
'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).',
111+
'Installs an extension from a git repository URL, local path or archive, archive URL, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).',
105112
),
106113
builder: (yargs) =>
107114
yargs
108115
.positional('source', {
109116
describe: t(
110-
'The github URL, local path, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
117+
'The github URL, local path or archive, archive URL, or marketplace source (marketplace-url:plugin-name) of the extension to install.',
111118
),
112119
type: 'string',
113120
demandOption: true,

packages/core/src/config/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -524,7 +524,7 @@ export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini';
524524

525525
export interface ExtensionInstallMetadata {
526526
source: string;
527-
type: 'git' | 'local' | 'link' | 'github-release' | 'npm';
527+
type: 'git' | 'local' | 'link' | 'github-release' | 'npm' | 'archive-url';
528528
originSource?: ExtensionOriginSource;
529529
releaseTag?: string; // Only present for github-release and npm installs.
530530
registryUrl?: string; // Only present for npm installs.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as fs from 'node:fs';
8+
import * as path from 'node:path';
9+
import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';
10+
import {
11+
convertGeminiExtensionPackage,
12+
isGeminiExtensionConfig,
13+
} from './gemini-converter.js';
14+
import { convertClaudePluginPackage } from './claude-converter.js';
15+
import type { ExtensionOriginSource } from '../config/config.js';
16+
17+
export async function convertGeminiOrClaudeExtension(
18+
extensionDir: string,
19+
pluginName?: string,
20+
): Promise<{ extensionDir: string; originSource: ExtensionOriginSource }> {
21+
let newExtensionDir = extensionDir;
22+
let originSource: ExtensionOriginSource = 'QwenCode';
23+
const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME);
24+
if (fs.existsSync(configFilePath)) {
25+
newExtensionDir = extensionDir;
26+
} else if (isGeminiExtensionConfig(extensionDir)) {
27+
newExtensionDir = (await convertGeminiExtensionPackage(extensionDir))
28+
.convertedDir;
29+
originSource = 'Gemini';
30+
} else if (pluginName) {
31+
newExtensionDir = (
32+
await convertClaudePluginPackage(extensionDir, pluginName)
33+
).convertedDir;
34+
originSource = 'Claude';
35+
}
36+
return { extensionDir: newExtensionDir, originSource };
37+
}

0 commit comments

Comments
 (0)