Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
21 changes: 21 additions & 0 deletions docs/design/qoder-plugin-compatibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Qoder Plugin Compatibility

## Context

Qwen Code installs extensions from directories, archives, Git repositories, archive URLs, and scoped npm packages. Each source is normalized to a local directory before its manifest is loaded. The [Qoder plugin layout](https://docs.qoder.com/en/cli/sdk/plugins) uses `.qoder-plugin/plugin.json` with standard commands, agents, skills, and a root `.mcp.json` file.

## Design

The existing compatible-extension conversion step recognizes the Qoder manifest alongside native Qwen, Gemini, and Claude manifests. It copies the plugin into a temporary extension directory, writes a generated `qwen-extension.json`, and records `Qoder` as the install origin. Converted Git installs record the checked-out commit in install metadata so update checks remain available after Git metadata is removed.

The generated manifest preserves `name`, `version`, `displayName`, and `description`. A missing version defaults to `1.0.0`. Standard resource directories remain in place, while existing resource path declarations use the same confined collection logic as other compatible plugin formats. Root `.mcp.json` servers are normalized to Qwen transports unless the manifest already defines MCP servers.

A safe root `system-prompt.md` is added to the extension context list. An existing `QWEN.md` and explicitly configured context files remain active alongside it, with duplicates removed.

## Security

The manifest must resolve within the plugin directory and parse as a JSON object with a valid name. Referenced resources and context files must remain inside the plugin. Bulk copying skips symlinks that escape the source root and omits Git metadata. Archive validation accepts the Qoder manifest at the archive root or inside one supported top-level wrapper directory.

## Compatibility

Adding `Qoder` to the shared extension-origin union lets CLI, daemon, SDK, ACP, and Web Shell consumers identify converted plugins without changing install commands or route shapes.
16 changes: 15 additions & 1 deletion docs/users/extension/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.

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.
Extensions and plugins from [Gemini CLI Extensions Gallery](https://geminicli.com/extensions/), [Claude Code Marketplace](https://claudemarketplaces.com/), and Qoder can be directly installed into Qwen Code. This cross-platform compatibility gives you access to a rich ecosystem of extensions and plugins, dramatically expanding Qwen Code's capabilities without requiring extension authors to maintain separate versions.

## Extension management

Expand Down Expand Up @@ -99,6 +99,20 @@ Gemini extensions are automatically converted to Qwen Code format during install
- TOML command files are automatically migrated to Markdown format
- MCP servers, context files, and settings are preserved

#### From Qoder Plugins

Qwen Code supports [Qoder plugins](https://docs.qoder.com/en/cli/sdk/plugins) that contain a `.qoder-plugin/plugin.json` manifest. Install a local directory, archive, Git repository, archive URL, or scoped npm package with the existing `qwen extensions install` command:

```bash
qwen extensions install ./sample-qoder-plugin
qwen extensions install ./sample-qoder-plugin.zip
qwen extensions install owner/sample-qoder-plugin
```

The installer converts the Qoder manifest to `qwen-extension.json` and preserves standard `commands/`, `agents/`, and `skills/` directories. MCP servers declared in a root `.mcp.json` file are included as extension MCP servers.

When a Qoder plugin contains `system-prompt.md` at its root, Qwen Code loads it as extension context. If the plugin also contains `QWEN.md` or declares other context files, all context files are retained and deduplicated.

#### From npm Registry

Qwen Code supports installing extensions from npm registries using scoped package names. This is ideal for teams with private registries that already have auth, versioning, and publishing infrastructure in place.
Expand Down
43 changes: 42 additions & 1 deletion integration-tests/cli/extensions-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { expect, test } from 'vitest';
import { TestRig } from '../test-helper.js';
import { writeFileSync } from 'node:fs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';

const extension = `{
Expand Down Expand Up @@ -50,3 +50,44 @@ test('installs a local extension, verifies a command, and updates it', async ()

await rig.cleanup();
});

test('installs a local Qoder plugin', async () => {
Comment thread
callmeYe marked this conversation as resolved.
const rig = new TestRig();
rig.setup('qoder plugin install test');
Comment thread
callmeYe marked this conversation as resolved.
const manifestDir = join(rig.testDir!, '.qoder-plugin');
mkdirSync(manifestDir, { recursive: true });
writeFileSync(
join(manifestDir, 'plugin.json'),
JSON.stringify({ name: 'sample-qoder-plugin', version: '1.0.0' }),
);
writeFileSync(join(rig.testDir!, 'system-prompt.md'), '# System context');
const skillDir = join(rig.testDir!, 'skills', 'sample-skill');
mkdirSync(skillDir, { recursive: true });
writeFileSync(
join(skillDir, 'SKILL.md'),
'---\nname: sample-skill\ndescription: Synthetic skill\n---\n',
);

try {
await rig.runCommand(['extensions', 'uninstall', 'sample-qoder-plugin']);
} catch {
// The extension is not installed yet.
}
try {
const result = await rig.runCommand(
['extensions', 'install', rig.testDir!],
Comment thread
callmeYe marked this conversation as resolved.
{ stdin: 'y\n' },
);
expect(result).toContain('sample-qoder-plugin');

const listResult = await rig.runCommand(['extensions', 'list']);
Comment thread
callmeYe marked this conversation as resolved.
expect(listResult).toContain('sample-qoder-plugin');
} finally {
try {
await rig.runCommand(['extensions', 'uninstall', 'sample-qoder-plugin']);
} catch {
// Installation may have failed before the extension was registered.
}
await rig.cleanup();
}
});
6 changes: 5 additions & 1 deletion packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1065,7 +1065,11 @@ export type ServeExtensionInstallType =
| 'npm'
| 'archive-url';

export type ServeExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini';
export type ServeExtensionOriginSource =
| 'QwenCode'
| 'Claude'
| 'Gemini'
| 'Qoder';

export interface ServeExtensionCapabilities {
mcpServerCount: number;
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,14 +672,16 @@ function normalizeGitCoAuthor(value: GitCoAuthorParam | undefined): {
};
}

export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini';
export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini' | 'Qoder';
export type ExtensionNetworkPolicy = 'public';

export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local' | 'link' | 'github-release' | 'npm' | 'archive-url';
originSource?: ExtensionOriginSource;
releaseTag?: string; // Only present for github-release and npm installs.
gitCommit?: string; // Commit recorded when the installation source was cloned.
externalContent?: boolean; // Installed content came from a source nested outside the recorded source.
registryUrl?: string; // Only present for npm installs.
ref?: string;
autoUpdate?: boolean;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/extension/claude-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => {
JSON.stringify({ name: 'p', version: '1.0.0' }),
'utf-8',
);
return 'test-commit';
});

writeMarketplace({
Expand Down Expand Up @@ -1422,6 +1423,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => {
vi.mocked(cloneFromGit).mockImplementation(async (_meta, dir) => {
// The clone succeeded but does not contain the requested subdir.
fs.mkdirSync(path.join(dir as string, 'other'), { recursive: true });
return 'test-commit';
});
writeMarketplace({
source: 'git-subdir',
Expand All @@ -1440,6 +1442,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => {
// A hostile repo commits the subdir as a symlink whose name stays inside
// the clone but whose target escapes it.
fs.symlinkSync(secretDir, path.join(dir as string, 'sub'));
return 'test-commit';
});
writeMarketplace({
source: 'git-subdir',
Expand Down
37 changes: 24 additions & 13 deletions packages/core/src/extension/claude-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,11 @@ export async function convertClaudePluginPackage(
pluginName: string,
networkPolicy?: ExtensionInstallMetadata['networkPolicy'],
signal?: AbortSignal,
): Promise<{ config: ExtensionConfig; convertedDir: string }> {
): Promise<{
config: ExtensionConfig;
convertedDir: string;
externalContent: boolean;
}> {
signal?.throwIfAborted();
// Step 1: Load marketplace.json
const marketplaceJsonPath = path.join(
Expand Down Expand Up @@ -493,7 +497,7 @@ export async function convertClaudePluginPackage(
);
await fs.promises.mkdir(pluginDir, { recursive: true });

const pluginSource = await resolvePluginSource(
const { pluginSource, externalContent } = await resolvePluginSource(
marketplacePlugin,
extensionDir,
pluginDir,
Expand Down Expand Up @@ -544,7 +548,11 @@ export async function convertClaudePluginPackage(
mergedConfig = marketplacePlugin as ClaudePluginConfig;
}

return buildQwenExtensionFromPlugin(pluginSource, mergedConfig);
const converted = await buildQwenExtensionFromPlugin(
pluginSource,
mergedConfig,
);
return { ...converted, externalContent };
}

/**
Expand All @@ -554,7 +562,7 @@ export async function convertClaudePluginPackage(
* could otherwise make the converter read sensitive files outside the plugin.
* Returns the confined absolute path, or null when the reference is unsafe.
*/
function resolvePluginRelativeFile(
export function resolvePluginRelativeFile(
pluginSource: string,
relativePath: string,
): string | null {
Expand Down Expand Up @@ -591,7 +599,7 @@ function resolvePluginRelativeFile(
* (`convertClaudePluginPackage`) and standalone (`convertClaudePluginStandalone`)
* conversion paths.
*/
async function buildQwenExtensionFromPlugin(
export async function buildQwenExtensionFromPlugin(
pluginSource: string,
mergedConfig: ClaudePluginConfig,
): Promise<{ config: ExtensionConfig; convertedDir: string }> {
Expand Down Expand Up @@ -1014,15 +1022,18 @@ export function isClaudePluginConfig(

/**
* Resolve plugin source from marketplace plugin configuration.
* Returns the absolute path to the plugin source directory.
* Returns the absolute path to the plugin source directory and whether the
* plugin content was fetched from a source external to the marketplace
* repository (in which case the marketplace clone's commit does not describe
* the installed content).
*/
async function resolvePluginSource(
pluginConfig: ClaudeMarketplacePluginConfig,
marketplaceDir: string,
pluginDir: string,
networkPolicy?: ExtensionInstallMetadata['networkPolicy'],
signal?: AbortSignal,
): Promise<string> {
): Promise<{ pluginSource: string; externalContent: boolean }> {
signal?.throwIfAborted();
const source = pluginConfig.source;

Expand All @@ -1047,7 +1058,7 @@ async function resolvePluginSource(
signal?.throwIfAborted();
await cloneFromGit(installMetadata, pluginDir, signal);
}
return pluginDir;
return { pluginSource: pluginDir, externalContent: true };
}

// Relative path within marketplace. Confine it: a manifest source like
Expand Down Expand Up @@ -1082,12 +1093,12 @@ async function resolvePluginSource(
// 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;
return { pluginSource: marketplaceDir, externalContent: false };
}

// Copy to plugin directory
await fs.promises.cp(sourcePath, pluginDir, { recursive: true });
return pluginDir;
return { pluginSource: pluginDir, externalContent: false };
}

// Handle object source (github or url)
Expand All @@ -1103,7 +1114,7 @@ async function resolvePluginSource(
signal?.throwIfAborted();
await cloneFromGit(installMetadata, pluginDir, signal);
}
return pluginDir;
return { pluginSource: pluginDir, externalContent: true };
}

if (source.source === 'url') {
Expand All @@ -1118,7 +1129,7 @@ async function resolvePluginSource(
signal?.throwIfAborted();
await cloneFromGit(installMetadata, pluginDir, signal);
}
return pluginDir;
return { pluginSource: pluginDir, externalContent: true };
}

if (source.source === 'git-subdir') {
Expand Down Expand Up @@ -1162,7 +1173,7 @@ async function resolvePluginSource(
`Plugin subdirectory "${sanitizeForError(source.path)}" resolves through a symlink outside the repository root of ${sanitizeForError(source.url)}`,
);
}
return subDir;
return { pluginSource: subDir, externalContent: true };
}

throw new Error(`Unsupported plugin source type: ${JSON.stringify(source)}`);
Expand Down
39 changes: 28 additions & 11 deletions packages/core/src/extension/extension-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
convertClaudePluginPackage,
convertClaudePluginStandalone,
} from './claude-converter.js';
import {
convertQoderPlugin,
QODER_PLUGIN_MANIFEST,
} from './qoder-converter.js';
import type {
ExtensionNetworkPolicy,
ExtensionOriginSource,
Expand All @@ -25,17 +29,23 @@ export const SUPPORTED_EXTENSION_MANIFESTS = [
'gemini-extension.json',
'.claude-plugin/marketplace.json',
'.claude-plugin/plugin.json',
QODER_PLUGIN_MANIFEST,
] as const;

export async function convertGeminiOrClaudeExtension(
export async function convertCompatibleExtension(
extensionDir: string,
pluginName?: string,
networkPolicy?: ExtensionNetworkPolicy,
signal?: AbortSignal,
): Promise<{ extensionDir: string; originSource: ExtensionOriginSource }> {
): Promise<{
extensionDir: string;
originSource: ExtensionOriginSource;
externalContent: boolean;
}> {
signal?.throwIfAborted();
let newExtensionDir = extensionDir;
let originSource: ExtensionOriginSource = 'QwenCode';
let externalContent = false;
const configFilePath = path.join(
extensionDir,
SUPPORTED_EXTENSION_MANIFESTS[0],
Expand All @@ -47,15 +57,22 @@ export async function convertGeminiOrClaudeExtension(
.convertedDir;
originSource = 'Gemini';
} else if (pluginName) {
newExtensionDir = (
await convertClaudePluginPackage(
extensionDir,
pluginName,
networkPolicy,
signal,
)
).convertedDir;
// An explicit marketplace selection must win over root-manifest
// detection: a repo can carry both a marketplace and a root plugin
// manifest, and silently substituting the latter installs different
// content than the one selected.
const converted = await convertClaudePluginPackage(
extensionDir,
pluginName,
networkPolicy,
signal,
);
newExtensionDir = converted.convertedDir;
originSource = 'Claude';
externalContent = converted.externalContent;
} else if (fs.existsSync(path.join(extensionDir, QODER_PLUGIN_MANIFEST))) {
newExtensionDir = (await convertQoderPlugin(extensionDir)).convertedDir;
originSource = 'Qoder';
} else if (
fs.existsSync(path.join(extensionDir, SUPPORTED_EXTENSION_MANIFESTS[3]))
) {
Expand All @@ -64,5 +81,5 @@ export async function convertGeminiOrClaudeExtension(
originSource = 'Claude';
}
signal?.throwIfAborted();
return { extensionDir: newExtensionDir, originSource };
return { extensionDir: newExtensionDir, originSource, externalContent };
}
Loading
Loading