Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
3f7cbda
feat(extensions): multi-tab /extensions dialog (Discover/Installed/Ma…
Jun 8, 2026
a63e37a
feat(extensions): align Discover plugin detail with Claude Code
Jun 8, 2026
86c760b
feat(extensions): align Add Marketplace view with Claude Code
Jun 8, 2026
d1f8e98
feat(extensions): fix Discover hang + add search/scrolling, align lis…
Jun 8, 2026
54c4f9f
feat(extensions): align marketplace detail with CC + Browse-to-Discover
Jun 8, 2026
d5bf144
feat(extensions): show plugin type in Installed; guide single-extensi…
Jun 8, 2026
d47d32a
fix(extensions): resolve git@ SSH marketplace sources
Jun 8, 2026
99d26a4
feat(extensions): cap Discover list window at 6 items
Jun 9, 2026
73d9b58
feat(extensions): unify 'Extension' wording, reorder tabs, expand Mar…
Jun 9, 2026
cff6ad4
feat(extensions): update Marketplaces tab footer hint
Jun 9, 2026
23f5dc1
feat(extensions): full extension actions in both tabs + context-aware…
Jun 9, 2026
dde9c59
feat(extensions): rename Marketplaces tab to Sources, hide Favorites …
Jun 9, 2026
3f891f9
feat(extensions): group Sources action rows + show current scope in s…
Jun 9, 2026
aed6a93
fix(extensions): move uninstall note to confirm step; complete zh/zh-…
Jun 9, 2026
6dcfb3a
feat(extensions): label Installed scope groups as 用户级/项目级/本地级
Jun 9, 2026
229de3a
feat(extensions): reuse /mcp server detail for installed MCP servers
Jun 9, 2026
2c49f01
fix(extensions): populate MCP promptCount from prompt registry
Jun 9, 2026
6c32c09
refactor(extensions): rename the source-management layer from marketp…
Jun 9, 2026
59f850c
fix(extensions): keep marketplaces.json filename so saved sources sur…
Jun 9, 2026
c83dd43
fix(extensions): stay on the Discover detail when an install fails
Jun 9, 2026
a5a70df
feat(extensions): support 'git-subdir' plugin source in Claude market…
Jun 10, 2026
0eacf78
feat(extensions): drop the unused 'local' install scope
Jun 10, 2026
d8919ed
feat(extensions): add Ctrl+R shortcut to refresh Discover tab
Jun 10, 2026
1abc5f8
fix(extensions): keep j/k typeable in Discover search
Jun 10, 2026
a52a5cd
feat(extensions): install standalone Claude Code plugins from a git URL
Jun 10, 2026
b98100c
i18n(zh): relabel user-scope install as 全局安装
Jun 10, 2026
bf46992
fix(extensions): keep the manager mounted during install consent prompts
Jun 10, 2026
c308b6e
feat(extensions): show loading feedback for scope change and toggles
Jun 10, 2026
fcdb7c1
feat(extensions): add --scope to install and a sources CLI command group
Jun 11, 2026
e24709d
feat(extensions): trim the Sources tab and add marketplace detail ret…
Jun 11, 2026
c5e49d5
feat(extensions): nest extension-bundled MCP servers under their exte…
Jun 12, 2026
12eb6e1
feat(extensions): per-server disable for extension MCPs and live status
Jun 12, 2026
e275ce2
Merge remote-tracking branch 'origin/main' into feat/enhance-extensio…
Jun 15, 2026
baeaf69
docs(extensions): document interactive manager and backfill missing i18n
Jun 15, 2026
05eb44f
fix(extensions): harden untrusted-marketplace handling from PR review
Jun 15, 2026
811e61b
fix(extensions): consent layout, per-extension update check, uninstal…
Jun 15, 2026
a8b745e
feat(extensions): clearer update-check feedback for "Mark for Update"
Jun 15, 2026
34f8252
fix(extensions): confine resource/source paths, sanitize homepage (re…
Jun 15, 2026
b050fe0
fix(extensions): confine symlink targets when converting untrusted pl…
Jun 15, 2026
1f302ac
fix(extensions): confine manifest reads, share path-containment helpers
Jun 16, 2026
7199a17
fix(extensions): cap manager dialog width to the main content area
Jun 16, 2026
cd5a11e
fix(diff): cap /diff dialog width to the main content area
Jun 16, 2026
3e5f7a5
Merge remote-tracking branch 'origin/main' into feat/enhance-extensio…
Copilot Jun 18, 2026
74a2d0e
Merge remote-tracking branch 'origin/main' into feat/enhance-extensio…
Copilot Jun 19, 2026
e08694f
fix(extensions): address review round 3 — symlink/ANSI confinement, c…
Jun 20, 2026
61a69e2
fix(extensions): guard toggleFavorite write against unhandled rejection
Jun 20, 2026
a7b2df2
test(extensions): normalize realpathSync mock so gemini guard passes …
Jun 20, 2026
b5fa71a
fix(extensions): address review round 4 — shared sanitizer, narrowed …
Jun 20, 2026
a2d63d7
test(extensions): assert discoverPlugins strips ANSI/control chars fr…
Jun 20, 2026
631e271
fix(extensions): address review round 5 — scope-change rollback, vers…
Jun 20, 2026
158002c
fix(extensions): address review round 6 — marketplace-name sanitizati…
Jun 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/users/extension/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ You can manage extensions at runtime within the interactive CLI using `/extensio
| `/extensions install <source>` | Install an extension from a git URL, local path, npm package, or marketplace |
| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser |

#### The interactive extension manager

Running `/extensions` (or `/extensions manage`) opens an interactive manager with three tabs. Press `Tab` or the `←`/`→` arrows to switch between them.

- **Discover** — browse plugins from your configured marketplace sources. Type to search, `Enter` to view a plugin's details, and install it (you'll be asked to choose an install scope). Press `Ctrl+R` to re-fetch the listings, and `Esc` to go back.
- **Installed** — your installed extensions, grouped by scope (**User level**, **Project level**, and favorites). Use `↑`/`↓` to navigate, `Space` to enable/disable an extension, `f` to favorite it, and `Enter` to open its details. MCP servers bundled by an extension appear nested under their parent extension with live connection status; you can enable or disable each server individually from there.
- **Sources** — manage the marketplace sources that feed the Discover tab. Use `↑`/`↓` to navigate, `Enter` to select a source, and `d` to remove one. These are the same sources managed by the `qwen extensions sources` CLI commands described below.

Changes made here hot-reload immediately, without restarting Qwen Code.

### CLI Extension Management

You can also manage extensions using `qwen extensions` CLI commands. Note that changes made via CLI commands will be reflected in active CLI sessions on restart.
Expand Down Expand Up @@ -133,6 +143,34 @@ qwen extensions install /path/to/your/extension

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.

#### Choosing an install scope

By default, an installed extension is enabled globally (user scope). Pass `--scope project` to enable it only for the current workspace:

```bash
qwen extensions install <source> --scope project
```

`--scope workspace` is accepted as an alias of `--scope project`. This matches the scope choice offered when installing from the `/extensions manage` Discover tab.

### Managing marketplace sources

Marketplace sources (Claude plugin marketplaces) power the Discover tab in `/extensions manage`. You can manage them from the CLI as well:

```bash
# Add a marketplace (owner/repo, git URL, https URL to marketplace.json, or local path)
qwen extensions sources add <source>

# List configured marketplaces
qwen extensions sources list

# Re-fetch a marketplace's plugin listing
qwen extensions sources update <name>

# Remove a marketplace
qwen extensions sources remove <name>
```

### Uninstalling an extension

To uninstall, run `qwen extensions uninstall extension-name`, so, in the case of the install example:
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { enableCommand } from './extensions/enable.js';
import { linkCommand } from './extensions/link.js';
import { newCommand } from './extensions/new.js';
import { settingsCommand } from './extensions/settings.js';
import { sourcesCommand } from './extensions/sources.js';

export const extensionsCommand: CommandModule = {
command: 'extensions <command>',
Expand All @@ -29,6 +30,7 @@ export const extensionsCommand: CommandModule = {
.command(linkCommand)
.command(newCommand)
.command(settingsCommand)
.command(sourcesCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
handler: () => {
Expand Down
141 changes: 141 additions & 0 deletions packages/cli/src/commands/extensions/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import yargs from 'yargs';

const mockInstallExtension = vi.hoisted(() => vi.fn());
const mockRefreshCache = vi.hoisted(() => vi.fn());
const mockSetExtensionScope = vi.hoisted(() => vi.fn());
const mockEnableExtension = vi.hoisted(() => vi.fn());
const mockDisableExtension = vi.hoisted(() => vi.fn());
const mockParseInstallSource = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockRequestConsentOrFail = vi.hoisted(() => vi.fn());
Expand All @@ -22,6 +25,9 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
ExtensionManager: vi.fn().mockImplementation(() => ({
installExtension: mockInstallExtension,
refreshCache: mockRefreshCache,
setExtensionScope: mockSetExtensionScope,
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
})),
parseInstallSource: mockParseInstallSource,
}));
Expand All @@ -38,6 +44,12 @@ vi.mock('../../config/trustedFolders.js', () => ({

vi.mock('../../config/settings.js', () => ({
loadSettings: mockLoadSettings,
SettingScope: {
User: 'User',
Workspace: 'Workspace',
System: 'System',
SystemDefaults: 'SystemDefaults',
},
}));

vi.mock('../../utils/errors.js', () => ({
Expand Down Expand Up @@ -225,4 +237,133 @@ describe('handleInstall', () => {

processSpy.mockRestore();
});

it('should re-scope enablement to the workspace for a project-scope install', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });

await handleInstall({ source: 'git@some-url', scope: 'project' });

expect(mockSetExtensionScope).toHaveBeenCalledWith(
'scoped-extension',
'project',
);
expect(mockDisableExtension).toHaveBeenCalledWith(
'scoped-extension',
'User',
);
expect(mockEnableExtension).toHaveBeenCalledWith(
'scoped-extension',
'Workspace',
);
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Extension "scoped-extension" installed successfully and enabled for the current workspace.',
);
});

it('rolls back the User-scope disable when the Workspace enable fails', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });
// Workspace enable (first call) fails; the rollback User enable succeeds.
mockEnableExtension.mockRejectedValueOnce(
new Error('workspace enable failed'),
);
mockEnableExtension.mockResolvedValueOnce(undefined);

await handleInstall({ source: 'git@some-url', scope: 'project' });

expect(mockDisableExtension).toHaveBeenCalledWith(
'scoped-extension',
'User',
);
// Both the failed Workspace enable and the rollback User enable were attempted.
expect(mockEnableExtension).toHaveBeenNthCalledWith(
1,
'scoped-extension',
'Workspace',
);
expect(mockEnableExtension).toHaveBeenNthCalledWith(
2,
'scoped-extension',
'User',
);
// The original failure is surfaced and the command exits non-zero.
expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed');
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});

it('surfaces a rollback failure when the recovery enable also fails', async () => {
const processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });
// Both the Workspace enable and the rollback User enable fail.
mockEnableExtension.mockRejectedValueOnce(
new Error('workspace enable failed'),
);
mockEnableExtension.mockRejectedValueOnce(new Error('rollback failed'));

await handleInstall({ source: 'git@some-url', scope: 'project' });

// A warning naming the failed rollback, plus the original error, are shown.
expect(mockWriteStderrLine).toHaveBeenCalledWith(
expect.stringContaining('failed to roll back the scope change'),
);
expect(mockWriteStderrLine).toHaveBeenCalledWith('workspace enable failed');
expect(processSpy).toHaveBeenCalledWith(1);
processSpy.mockRestore();
});

it('should accept workspace as an alias of project scope', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'scoped-extension' });

await handleInstall({ source: 'git@some-url', scope: 'workspace' });

expect(mockSetExtensionScope).toHaveBeenCalledWith(
'scoped-extension',
'project',
);
expect(mockEnableExtension).toHaveBeenCalledWith(
'scoped-extension',
'Workspace',
);
});

it('should record user scope without re-scoping enablement', async () => {
mockParseInstallSource.mockResolvedValue({
type: 'git',
url: 'git@some-url',
});
mockInstallExtension.mockResolvedValue({ name: 'user-extension' });

await handleInstall({ source: 'git@some-url', scope: 'user' });

expect(mockSetExtensionScope).toHaveBeenCalledWith(
'user-extension',
'user',
);
expect(mockDisableExtension).not.toHaveBeenCalled();
expect(mockEnableExtension).not.toHaveBeenCalled();
expect(mockWriteStdoutLine).toHaveBeenCalledWith(
'Extension "user-extension" installed successfully and enabled.',
);
});
});
68 changes: 64 additions & 4 deletions packages/cli/src/commands/extensions/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import type { CommandModule } from 'yargs';
import {
ExtensionManager,
parseInstallSource,
type ExtensionScope,
} from '@qwen-code/qwen-code-core';
import { getErrorMessage } from '../../utils/errors.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { loadSettings } from '../../config/settings.js';
import { loadSettings, SettingScope } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
Expand All @@ -28,6 +29,12 @@ interface InstallArgs {
allowPreRelease?: boolean;
consent?: boolean;
registry?: string;
scope?: string;
}

// "workspace" is accepted as an alias of "project" to match enable/disable.
function normalizeScope(scope: string | undefined): ExtensionScope {
return scope === 'project' || scope === 'workspace' ? 'project' : 'user';
}

export async function handleInstall(args: InstallArgs) {
Expand Down Expand Up @@ -87,10 +94,55 @@ export async function handleInstall(args: InstallArgs) {
},
requestConsent,
);
const scope = normalizeScope(args.scope);
if (args.scope) {
// installExtension auto-enables at the user (global) scope. For a
// project-scoped install, re-scope enablement to this workspace only —
// BEFORE recording the scope preference, so a failed Workspace enable
// (which rolls back to User) can't leave the prefs claiming "project".
if (scope === 'project') {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Non-atomic scope change: disableExtension(User) succeeds, then enableExtension(Workspace) can fail — leaving the extension disabled at both scopes with no rollback. The user sees an error but the extension is already disabled globally.

The same pattern appears in two other locations: ExtensionActionsView.tsx (handleScope) and DiscoverTab.tsx (runInstall). In the batch-install case, the scope error is only logged to debugLogger.error — the user sees "Installed N extension(s)" as success while one or more are silently disabled.

Suggested change
if (scope === 'project') {
if (scope === 'project') {
await extensionManager.disableExtension(
extension.name,
SettingScope.User,
);
try {
await extensionManager.enableExtension(
extension.name,
SettingScope.Workspace,
);
} catch (enableError) {
// Rollback: re-enable at User scope so the extension isn't left
// disabled everywhere.
try {
await extensionManager.enableExtension(
extension.name,
SettingScope.User,
);
} catch { /* best-effort rollback */ }
throw enableError;
}
}

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e08694f for the CLI install command — the Workspace enable is now wrapped so a failure rolls the User-scope disable back (best-effort) before rethrowing, so the extension isn't left disabled everywhere.

I scoped this to install.ts. The DiscoverTab.runInstall / ExtensionActionsView.handleScope occurrences share the pattern, but their scope step is already isolated in its own try/catch that only logs a preference warning without flipping the install result — I'd rather give those two the same rollback in a focused follow-up than partially address them here.

await extensionManager.disableExtension(
extension.name,
SettingScope.User,
);
try {
await extensionManager.enableExtension(
extension.name,
SettingScope.Workspace,
);
} catch (enableError) {
// The User-scope disable already landed. If the Workspace enable
// fails, the extension would be left disabled everywhere — roll the
// User enable back so it isn't silently dead, then surface the error.
try {
await extensionManager.enableExtension(
extension.name,
SettingScope.User,
);
} catch (rollbackError) {
// Rollback failed too: the extension is now disabled at every
// scope. Surface this so the user knows recovery also failed,
// before the original error is reported below.
writeStderrLine(
`Warning: failed to roll back the scope change for "${extension.name}"; it may be disabled at all scopes: ${getErrorMessage(rollbackError)}`,
);
}
throw enableError;
}
}
// Enablement succeeded (or scope is user/local with no enablement change):
// now it's safe to persist the scope preference.
extensionManager.setExtensionScope(extension.name, scope);
}
writeStdoutLine(
t('Extension "{{name}}" installed successfully and enabled.', {
name: extension.name,
}),
scope === 'project'
? t(
'Extension "{{name}}" installed successfully and enabled for the current workspace.',
{ name: extension.name },
)
: t('Extension "{{name}}" installed successfully and enabled.', {
name: extension.name,
}),
);
} catch (error) {
writeStderrLine(getErrorMessage(error));
Expand Down Expand Up @@ -135,6 +187,13 @@ export const installCommand: CommandModule = {
type: 'boolean',
default: false,
})
.option('scope', {
describe: t(
'The scope to install the extension in: "user" (global, default) or "project" (current workspace only).',
),
type: 'string',
choices: ['user', 'project', 'workspace'],
})
.check((argv) => {
if (!argv.source) {
throw new Error(t('The source argument must be provided.'));
Expand All @@ -149,6 +208,7 @@ export const installCommand: CommandModule = {
allowPreRelease: argv['pre-release'] as boolean | undefined,
consent: argv['consent'] as boolean | undefined,
registry: argv['registry'] as string | undefined,
scope: argv['scope'] as string | undefined,
});
},
};
Loading
Loading