diff --git a/docs/design/qoder-plugin-compatibility.md b/docs/design/qoder-plugin-compatibility.md new file mode 100644 index 00000000000..a09fa0152db --- /dev/null +++ b/docs/design/qoder-plugin-compatibility.md @@ -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. diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 098db12fffe..4c6ca5ffe70 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -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 @@ -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. diff --git a/integration-tests/cli/extensions-install.test.ts b/integration-tests/cli/extensions-install.test.ts index 58afd4024a0..49c1975dcd9 100644 --- a/integration-tests/cli/extensions-install.test.ts +++ b/integration-tests/cli/extensions-install.test.ts @@ -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 = `{ @@ -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 () => { + const rig = new TestRig(); + rig.setup('qoder plugin install test'); + 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!], + { stdin: 'y\n' }, + ); + expect(result).toContain('sample-qoder-plugin'); + + const listResult = await rig.runCommand(['extensions', 'list']); + 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(); + } +}); diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 4bd6c5e815b..a8059a4d48a 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -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; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index ad186d82428..283f0e678ce 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -672,7 +672,7 @@ 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 { @@ -680,6 +680,8 @@ export interface ExtensionInstallMetadata { 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; diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 04f3494fa03..1d2f499d9a2 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -1371,6 +1371,7 @@ describe('convertClaudePluginPackage — git-subdir source', () => { JSON.stringify({ name: 'p', version: '1.0.0' }), 'utf-8', ); + return 'test-commit'; }); writeMarketplace({ @@ -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', @@ -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', diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 0c47a7c04a4..afef729db4a 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -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( @@ -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, @@ -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 }; } /** @@ -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 { @@ -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 }> { @@ -1014,7 +1022,10 @@ 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, @@ -1022,7 +1033,7 @@ async function resolvePluginSource( pluginDir: string, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], signal?: AbortSignal, -): Promise { +): Promise<{ pluginSource: string; externalContent: boolean }> { signal?.throwIfAborted(); const source = pluginConfig.source; @@ -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 @@ -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) @@ -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') { @@ -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') { @@ -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)}`); diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index d3fa57a9abd..2bc766b5398 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -15,6 +15,10 @@ import { convertClaudePluginPackage, convertClaudePluginStandalone, } from './claude-converter.js'; +import { + convertQoderPlugin, + QODER_PLUGIN_MANIFEST, +} from './qoder-converter.js'; import type { ExtensionNetworkPolicy, ExtensionOriginSource, @@ -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], @@ -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])) ) { @@ -64,5 +81,5 @@ export async function convertGeminiOrClaudeExtension( originSource = 'Claude'; } signal?.throwIfAborted(); - return { extensionDir: newExtensionDir, originSource }; + return { extensionDir: newExtensionDir, originSource, externalContent }; } diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index 9e64ffe0005..ddd899f2de7 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -44,6 +44,7 @@ const mockGit = { }; const mockDownloadFromArchiveUrl = vi.hoisted(() => vi.fn()); const mockExtractArchiveFile = vi.hoisted(() => vi.fn()); +const mockDownloadFromNpmRegistry = vi.hoisted(() => vi.fn()); vi.mock('simple-git', () => ({ CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' }, @@ -65,6 +66,14 @@ vi.mock('./github.js', async (importOriginal) => { }; }); +vi.mock('./npm.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + downloadFromNpmRegistry: mockDownloadFromNpmRegistry, + }; +}); + const mockHomedir = vi.hoisted(() => vi.fn()); vi.mock('os', async (importOriginal) => { const mockedOs = await importOriginal(); @@ -153,6 +162,8 @@ describe('extension tests', () => { Object.values(mockGit).forEach((fn) => fn.mockReset()); mockDownloadFromArchiveUrl.mockReset(); mockExtractArchiveFile.mockReset(); + mockDownloadFromNpmRegistry.mockReset(); + mockGit.revparse.mockResolvedValue('sample-commit'); }); afterEach(() => { @@ -187,6 +198,20 @@ describe('extension tests', () => { ); } + function writeQoderPlugin(destination: string) { + fs.mkdirSync(path.join(destination, '.qoder-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(destination, '.qoder-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-qoder-plugin', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(destination, 'system-prompt.md'), + '# System context', + ); + } + it('installs and uninstalls within an injected extension store root', async () => { const archivePath = path.join(tempWorkspaceDir, 'custom-root.zip'); fs.writeFileSync(archivePath, 'archive'); @@ -676,6 +701,355 @@ describe('extension tests', () => { }); }); + it('should install a Qoder plugin with skills and system context', async () => { + const sourcePath = path.join(tempWorkspaceDir, 'sample-qoder-plugin'); + fs.mkdirSync(path.join(sourcePath, '.qoder-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.qoder-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-qoder-plugin', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(sourcePath, 'system-prompt.md'), + '# System context', + ); + const skillPath = path.join(sourcePath, 'skills', 'sample-skill'); + fs.mkdirSync(skillPath, { recursive: true }); + fs.writeFileSync( + path.join(skillPath, 'SKILL.md'), + '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', + ); + const commandsPath = path.join(sourcePath, 'commands'); + fs.mkdirSync(commandsPath, { recursive: true }); + fs.writeFileSync( + path.join(commandsPath, 'sample.md'), + '# Command\n${CLAUDE_PLUGIN_ROOT}/scripts/run.sh', + ); + const hooksPath = path.join(sourcePath, 'hooks'); + fs.mkdirSync(hooksPath, { recursive: true }); + fs.writeFileSync(path.join(hooksPath, 'hooks.json'), '{}'); + const requestConsent = vi.fn(async () => {}); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { source: sourcePath, type: 'local' }, + requestConsent, + ); + + expect(extension.installMetadata).toMatchObject({ + source: sourcePath, + type: 'local', + originSource: 'Qoder', + }); + expect(extension.contextFiles).toEqual([ + path.join(extension.path, 'system-prompt.md'), + ]); + expect(extension.skills?.map((skill) => skill.name)).toEqual([ + 'sample-skill', + ]); + expect( + fs.readFileSync( + path.join(extension.path, 'commands', 'sample.md'), + 'utf-8', + ), + ).toContain(`${extension.path}/scripts/run.sh`); + expect(requestConsent).toHaveBeenCalledWith( + expect.objectContaining({ originSource: 'Qoder' }), + ); + }); + + it.each([ + { + type: 'local' as const, + source: 'sample-qoder-plugin.zip', + }, + { + type: 'archive-url' as const, + source: 'https://example.com/sample-qoder-plugin.zip', + }, + { + type: 'npm' as const, + source: '@example/sample-qoder-plugin', + }, + ])('should install a Qoder plugin from $type', async (installMetadata) => { + const resolvedInstallMetadata = + installMetadata.type === 'local' + ? { + ...installMetadata, + source: path.join(tempWorkspaceDir, installMetadata.source), + } + : installMetadata; + if (resolvedInstallMetadata.type === 'local') { + fs.writeFileSync(resolvedInstallMetadata.source, 'synthetic archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + writeQoderPlugin(destination); + }, + ); + } else if (installMetadata.type === 'archive-url') { + mockDownloadFromArchiveUrl.mockImplementation( + async (_metadata: ExtensionInstallMetadata, destination: string) => { + writeQoderPlugin(destination); + }, + ); + } else { + mockDownloadFromNpmRegistry.mockImplementation( + async (_metadata: ExtensionInstallMetadata, destination: string) => { + writeQoderPlugin(destination); + return { version: '1.0.0', type: 'npm' }; + }, + ); + } + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + resolvedInstallMetadata, + async () => {}, + ); + + expect(extension.name).toBe('sample-qoder-plugin'); + expect(extension.installMetadata?.originSource).toBe('Qoder'); + expect(extension.contextFiles).toEqual([ + path.join(extension.path, 'system-prompt.md'), + ]); + }); + + it('should install a Qoder plugin from Git', async () => { + mockGit.clone.mockImplementation(async () => { + writeQoderPlugin(mockGit.path()); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-qoder-plugin' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + }, + async () => {}, + ); + + expect(extension.name).toBe('sample-qoder-plugin'); + expect(extension.installMetadata?.originSource).toBe('Qoder'); + expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + }); + + it('should retain the recorded commit for a converted Claude Git plugin', async () => { + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-claude-plugin', version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-claude-plugin' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-claude-plugin', + }, + async () => {}, + ); + + expect(extension.installMetadata?.originSource).toBe('Claude'); + expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + }); + + it('should retain the recorded commit when a marketplace plugin lives in the marketplace repo', async () => { + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Example', email: 'example@example.com' }, + plugins: [ + { name: 'sample-plugin', source: './plugins/sample-plugin' }, + ], + }), + ); + const pluginConfigDir = path.join( + sourcePath, + 'plugins', + 'sample-plugin', + '.claude-plugin', + ); + fs.mkdirSync(pluginConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginConfigDir, 'plugin.json'), + JSON.stringify({ name: 'sample-plugin', version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-marketplace' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-marketplace', + pluginName: 'sample-plugin', + }, + async () => {}, + ); + + expect(extension.name).toBe('sample-plugin'); + expect(extension.installMetadata?.originSource).toBe('Claude'); + expect(extension.installMetadata?.gitCommit).toBe('sample-commit'); + expect(extension.installMetadata?.externalContent).toBe(false); + }); + + it('should drop the recorded commit when a marketplace plugin resolves from an external source', async () => { + let cloneCalls = 0; + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + cloneCalls += 1; + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + if (cloneCalls === 1) { + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Example', email: 'example@example.com' }, + plugins: [ + { + name: 'sample-plugin', + source: { source: 'github', repo: 'example/nested-plugin' }, + }, + ], + }), + ); + } else { + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-plugin', version: '1.0.0' }), + ); + } + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/sample-marketplace' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-marketplace', + pluginName: 'sample-plugin', + }, + async () => {}, + ); + + expect(extension.name).toBe('sample-plugin'); + expect(extension.installMetadata?.originSource).toBe('Claude'); + expect(extension.installMetadata?.gitCommit).toBeUndefined(); + expect(extension.installMetadata?.externalContent).toBe(true); + }); + + it('should mark external marketplace content downloaded from a GitHub release as not independently updatable', async () => { + const { downloadFromGitHubRelease } = await import('./github.js'); + vi.mocked(downloadFromGitHubRelease).mockImplementationOnce( + async (_metadata, destination) => { + fs.mkdirSync(path.join(destination, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(destination, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Example', email: 'example@example.com' }, + plugins: [ + { + name: 'sample-plugin', + source: { source: 'github', repo: 'example/nested-plugin' }, + }, + ], + }), + ); + return { type: 'github-release', tagName: 'v1.0.0' }; + }, + ); + mockGit.clone.mockImplementation(async () => { + const sourcePath = mockGit.path(); + fs.mkdirSync(path.join(sourcePath, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(sourcePath, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'sample-plugin', version: '1.0.0' }), + ); + }); + mockGit.getRemotes.mockResolvedValue([ + { + name: 'origin', + refs: { fetch: 'https://github.com/example/nested-plugin' }, + }, + ]); + mockGit.fetch.mockResolvedValue(undefined); + mockGit.checkout.mockResolvedValue(undefined); + const manager = createExtensionManager(); + await manager.refreshCache(); + + const extension = await manager.installExtension( + { + type: 'git', + source: 'https://github.com/example/sample-marketplace', + pluginName: 'sample-plugin', + }, + async () => {}, + ); + + expect(extension.installMetadata).toMatchObject({ + type: 'github-release', + releaseTag: 'v1.0.0', + originSource: 'Claude', + externalContent: true, + }); + expect(extension.installMetadata?.gitCommit).toBeUndefined(); + }); + it('should emit mutation lifecycle events around install', async () => { const archivePath = path.join(tempWorkspaceDir, 'local-extension.zip'); fs.writeFileSync(archivePath, 'not used by mocked extractor'); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 31f308b5ab6..a4d60e778e5 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -63,7 +63,7 @@ import { loadMarketplaceConfigFromSource, parseInstallSource, } from './marketplace.js'; -import { convertGeminiOrClaudeExtension } from './extension-converter.js'; +import { convertCompatibleExtension } from './extension-converter.js'; import { glob } from 'glob'; import { createHash } from 'node:crypto'; import { ExtensionStorage } from './storage.js'; @@ -1768,7 +1768,11 @@ export class ExtensionManager { // See #6334. await fs.promises.rm(tempDir, { recursive: true, force: true }); await fs.promises.mkdir(tempDir, { recursive: true }); - await cloneFromGit(installMetadata, tempDir, signal); + installMetadata.gitCommit = await cloneFromGit( + installMetadata, + tempDir, + signal, + ); if (installMetadata.type === 'github-release') { installMetadata.type = 'git'; } @@ -1806,8 +1810,8 @@ export class ExtensionManager { signal?.throwIfAborted(); try { const sourceBeforeConversion = localSourcePath; - const { extensionDir, originSource } = - await convertGeminiOrClaudeExtension( + const { extensionDir, originSource, externalContent } = + await convertCompatibleExtension( sourceBeforeConversion, installMetadata.pluginName, installMetadata.networkPolicy, @@ -1820,6 +1824,13 @@ export class ExtensionManager { } localSourcePath = extensionDir; installMetadata.originSource = originSource; + installMetadata.externalContent = externalContent; + if (externalContent) { + // The commit recorded above belongs to the outer clone (e.g. the + // marketplace repo), not plugin content fetched from a nested + // source; drop it so update checks don't compare the wrong repo. + installMetadata.gitCommit = undefined; + } newExtensionConfig = this.loadExtensionConfig({ extensionDir: localSourcePath, @@ -1950,11 +1961,12 @@ export class ExtensionManager { : path.join(stagingPath, newExtensionConfig.hooks) : null; + const usesPluginVariables = + originSource === 'Claude' || originSource === 'Qoder'; if ( - (originSource === 'Claude' && fs.existsSync(hooksDir)) || - (originSource === 'Claude' && - configHooksPath && - fs.existsSync(configHooksPath)) + usesPluginVariables && + (fs.existsSync(hooksDir) || + (configHooksPath && fs.existsSync(configHooksPath))) ) { try { await performVariableReplacement(stagingPath, destinationPath); diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index 8cdf5643c58..b45952b4f76 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -36,6 +36,7 @@ import { } from './extensionManager.js'; import { getErrorMessage } from '../utils/errors.js'; import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; +import { QODER_PLUGIN_MANIFEST } from './qoder-converter.js'; import { ExtensionStorage } from './storage.js'; import { assertTarArchiveHasNoLinks } from './archive-safety.js'; @@ -60,7 +61,12 @@ vi.mock('node:https', async (importOriginal) => { vi.mock('simple-git'); describe('git extension helpers', () => { + beforeEach(() => { + vi.stubEnv('GITHUB_TOKEN', ''); + }); + afterEach(() => { + vi.unstubAllEnvs(); vi.restoreAllMocks(); mockHttpsGet.mockReset(); }); @@ -147,6 +153,7 @@ describe('git extension helpers', () => { getRemotes: vi.fn(), fetch: vi.fn(), checkout: vi.fn(), + revparse: vi.fn(), version: vi.fn(), env: vi.fn(), }; @@ -155,6 +162,7 @@ describe('git extension helpers', () => { vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit); mockGit.env.mockReturnValue(mockGit); mockGit.version.mockResolvedValue({ major: 2, minor: 52 }); + mockGit.revparse.mockResolvedValue('local-hash'); }); it('should clone, fetch and checkout a repo', async () => { @@ -170,7 +178,11 @@ describe('git extension helpers', () => { ]); const controller = new AbortController(); - await cloneFromGit(installMetadata, destination, controller.signal); + const commit = await cloneFromGit( + installMetadata, + destination, + controller.signal, + ); expect(simpleGit).toHaveBeenCalledWith(destination, { abort: controller.signal, @@ -187,6 +199,7 @@ describe('git extension helpers', () => { 'my-ref', ); expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD'); + expect(commit).toBe('local-hash'); }); it('should use core.symlinks=false on Windows to avoid permission errors', async () => { @@ -561,6 +574,148 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); }); + it.each(['Qoder', 'Claude'] as const)( + 'checks a converted %s Git extension using its recorded commit', + async (originSource) => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource, + gitCommit: 'local-hash', + }, + }); + mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD'); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(mockGit.getRemotes).not.toHaveBeenCalled(); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/example/sample-qoder-plugin', + 'HEAD', + ]); + }, + ); + + it('uses the peeled commit when checking a recorded annotated tag', async () => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource: 'Qoder', + gitCommit: 'local-hash', + ref: 'v1.0.0', + }, + }); + mockGit.listRemote.mockResolvedValue( + 'tag-hash\trefs/tags/v1.0.0\nlocal-hash\trefs/tags/v1.0.0^{}', + ); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(mockGit.listRemote).toHaveBeenCalledWith([ + 'https://github.com/example/sample-qoder-plugin', + 'v1.0.0', + 'v1.0.0^{}', + ]); + }); + + it.each(['Qoder', 'Claude'] as const)( + 'does not update-check legacy %s Git installs without a recorded commit', + async (originSource) => { + const extension = createExtension({ + installMetadata: { + type: 'git', + source: 'https://github.com/example/sample-qoder-plugin', + originSource, + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + }, + ); + + it.each(['git', 'github-release'] as const)( + 'does not update-check external marketplace content installed through %s', + async (type) => { + const extension = createExtension({ + installMetadata: { + type, + source: 'https://github.com/example/sample-marketplace', + originSource: 'Claude', + releaseTag: 'v1.0.0', + externalContent: true, + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockGit.getRemotes).not.toHaveBeenCalled(); + expect(mockGit.listRemote).not.toHaveBeenCalled(); + expect(mockHttpsGet).not.toHaveBeenCalled(); + }, + ); + + it('does not update-check legacy Claude marketplace releases without content provenance', async () => { + const extension = createExtension({ + installMetadata: { + type: 'github-release', + source: 'https://github.com/example/sample-marketplace', + originSource: 'Claude', + pluginName: 'sample-plugin', + releaseTag: 'v1.0.0', + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockHttpsGet).not.toHaveBeenCalled(); + }); + + it('update-checks marketplace releases with confirmed repository content', async () => { + mockHttpsResponses(JSON.stringify({ tag_name: 'v2.0.0' })); + const extension = createExtension({ + installMetadata: { + type: 'github-release', + source: 'https://github.com/example/sample-marketplace', + originSource: 'Claude', + pluginName: 'sample-plugin', + releaseTag: 'v1.0.0', + externalContent: false, + }, + }); + + const result = await checkForExtensionUpdate( + extension, + mockExtensionManager, + ); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(mockHttpsGet).toHaveBeenCalledOnce(); + }); + it('pins public Git update checks and disables redirects and proxies', async () => { vi.spyOn(dns, 'lookup').mockResolvedValue([ { address: '8.8.8.8', family: 4 }, @@ -702,6 +857,78 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); }); + it('should convert a local Qoder plugin before checking for updates', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-qoder-update-test-'), + ); + try { + await fs.mkdir(path.join(tempDir, '.qoder-plugin')); + await fs.writeFile( + path.join(tempDir, QODER_PLUGIN_MANIFEST), + JSON.stringify({ name: 'sample-qoder-plugin', version: '2.0.0' }), + ); + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempDir, + originSource: 'Qoder', + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn( + ({ extensionDir }: { extensionDir: string }) => + JSON.parse( + fsSync.readFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ), + ), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + + expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(await fs.readdir(tempDir)).toEqual(['.qoder-plugin']); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('does not convert a local marketplace checkout during update checks', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-marketplace-update-test-'), + ); + try { + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: tempDir, + originSource: 'Claude', + pluginName: 'sample-plugin', + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn().mockReturnValue({ + name: 'sample-plugin', + version: '1.0.0', + }), + } as unknown as ExtensionManager; + + const result = await checkForExtensionUpdate(extension, mockManager); + + expect(result).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(mockManager.loadExtensionConfig).toHaveBeenCalledWith({ + extensionDir: tempDir, + }); + expect(await fs.readdir(tempDir)).toEqual([]); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('should return NOT_UPDATABLE for local extension when source cannot be loaded', async () => { const extension = createExtension({ version: '1.0.0', @@ -1755,6 +1982,30 @@ describe('git extension helpers', () => { ).resolves.toContain('tar-wrapped-extension'); }); + it('should extract and flatten a wrapped Qoder plugin archive', async () => { + const archivePath = path.join(tempDir, 'wrapped-qoder-plugin.zip'); + const archive = await createZipBuffer(tempDir, [ + { + name: `wrapped/${QODER_PLUGIN_MANIFEST}`, + content: JSON.stringify({ name: 'sample-qoder-plugin' }), + }, + { + name: 'wrapped/system-prompt.md', + content: '# System context', + }, + ]); + await fs.writeFile(archivePath, archive); + + await extractArchiveFile(archivePath, tempDir); + + await expect( + fs.readFile(path.join(tempDir, QODER_PLUGIN_MANIFEST), 'utf-8'), + ).resolves.toContain('sample-qoder-plugin'); + await expect( + fs.readFile(path.join(tempDir, 'system-prompt.md'), 'utf-8'), + ).resolves.toBe('# System context'); + }); + it('should flatten wrapped archives when the archive file is in the destination', async () => { const archivePath = path.join(tempDir, 'downloaded-extension.zip'); const archiveBuildDir = path.join(tempDir, 'archive-build'); diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index d9f23be5f66..f293a80a926 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -23,7 +23,7 @@ import type { ExtensionInstallMetadata } from '../config/config.js'; import { checkNpmUpdate } from './npm.js'; import { redactUrlCredentials } from './redaction.js'; import { - convertGeminiOrClaudeExtension, + convertCompatibleExtension, SUPPORTED_EXTENSION_MANIFESTS, } from './extension-converter.js'; import { assertTarArchiveHasNoLinks } from './archive-safety.js'; @@ -109,6 +109,25 @@ function getGitHubToken(): string | undefined { return process.env['GITHUB_TOKEN']; } +function addGitHubToken(source: string): string { + const token = getGitHubToken(); + if (!token) return source; + try { + const parsedUrl = new URL(source); + if ( + parsedUrl.protocol === 'https:' && + parsedUrl.hostname === 'github.com' && + !parsedUrl.username + ) { + parsedUrl.username = token; + return parsedUrl.toString(); + } + } catch { + return source; + } + return source; +} + async function assertPinnedGitSupported(): Promise { const { simpleGit } = await loadSimpleGit(); const version = await simpleGit().version(); @@ -165,7 +184,7 @@ export async function cloneFromGit( installMetadata: ExtensionInstallMetadata, destination: string, signal?: AbortSignal, -): Promise { +): Promise { const redactedSource = redactUrlCredentials(installMetadata.source); try { const { simpleGit } = await loadSimpleGit(); @@ -200,25 +219,7 @@ export async function cloneFromGit( installMetadata.networkPolicy, ); signal?.throwIfAborted(); - let sourceUrl = installMetadata.source; - const token = getGitHubToken(); - if (token) { - try { - const parsedUrl = new URL(sourceUrl); - if ( - parsedUrl.protocol === 'https:' && - parsedUrl.hostname === 'github.com' - ) { - if (!parsedUrl.username) { - parsedUrl.username = token; - } - sourceUrl = parsedUrl.toString(); - } - } catch { - // If source is not a valid URL, we don't inject the token. - // We let git handle the source as is. - } - } + const sourceUrl = addGitHubToken(installMetadata.source); // On Windows, symlinks require elevated privileges by default, so we // disable them to avoid "Permission denied" errors during checkout. const symlinkValue = os.platform() === 'win32' ? 'false' : 'true'; @@ -247,6 +248,7 @@ export async function cloneFromGit( // Detached HEAD is expected here — we only need the fetched content. await git.checkout('FETCH_HEAD'); signal?.throwIfAborted(); + return (await git.revparse(['HEAD'])).trim(); } catch (error) { if ( signal?.aborted && @@ -325,18 +327,22 @@ export async function checkForExtensionUpdate( signal?.throwIfAborted(); await extractArchiveFile(installMetadata.source, tempDir, signal); signal?.throwIfAborted(); - const converted = await convertGeminiOrClaudeExtension( - tempDir, + extensionDir = tempDir; + } + if (tempDir !== undefined || installMetadata.originSource === 'Qoder') { + const sourceBeforeConversion = extensionDir; + const converted = await convertCompatibleExtension( + sourceBeforeConversion, installMetadata.pluginName, installMetadata.networkPolicy, signal, ); extensionDir = converted.extensionDir; - if (extensionDir !== tempDir) { + if (extensionDir !== sourceBeforeConversion) { convertedDir = extensionDir; } - signal?.throwIfAborted(); } + signal?.throwIfAborted(); latestConfig = extensionManager.loadExtensionConfig({ extensionDir, }); @@ -377,7 +383,7 @@ export async function checkForExtensionUpdate( path.join(os.tmpdir(), 'extension-archive-update-'), ); await downloadFromArchiveUrl(installMetadata, tempDir, signal); - const converted = await convertGeminiOrClaudeExtension( + const converted = await convertCompatibleExtension( tempDir, installMetadata.pluginName, installMetadata.networkPolicy, @@ -417,34 +423,56 @@ export async function checkForExtensionUpdate( } if ( !installMetadata || - installMetadata.originSource === 'Claude' || (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') ) { return ExtensionUpdateState.NOT_UPDATABLE; } + if ( + installMetadata.externalContent === true || + (installMetadata.externalContent === undefined && + installMetadata.originSource === 'Claude' && + installMetadata.pluginName !== undefined) + ) { + return ExtensionUpdateState.NOT_UPDATABLE; + } try { if (installMetadata.type === 'git') { const { simpleGit } = await loadSimpleGit(); if (installMetadata.networkPolicy === 'public') { await assertPinnedGitSupported(); } - const localGit = simpleGit( - extension.path, - signal ? { abort: signal } : undefined, - ); - const remotes = await localGit.getRemotes(true); - signal?.throwIfAborted(); - if (remotes.length === 0) { - debugLogger.error('No git remotes found.'); - return ExtensionUpdateState.ERROR; - } - const remoteUrl = remotes[0].refs.fetch; - if (!remoteUrl) { - debugLogger.error( - `No fetch URL found for git remote ${remotes[0].name}.`, + let remoteUrl: string; + let localHash: string; + if (installMetadata.gitCommit) { + remoteUrl = addGitHubToken(installMetadata.source); + localHash = installMetadata.gitCommit; + } else { + if ( + installMetadata.originSource === 'Claude' || + installMetadata.originSource === 'Qoder' + ) { + return ExtensionUpdateState.NOT_UPDATABLE; + } + const localGit = simpleGit( + extension.path, + signal ? { abort: signal } : undefined, ); - return ExtensionUpdateState.ERROR; + const remotes = await localGit.getRemotes(true); + signal?.throwIfAborted(); + if (remotes.length === 0) { + debugLogger.error('No git remotes found.'); + return ExtensionUpdateState.ERROR; + } + const fetchedRemoteUrl = remotes[0].refs.fetch; + if (!fetchedRemoteUrl) { + debugLogger.error( + `No fetch URL found for git remote ${remotes[0].name}.`, + ); + return ExtensionUpdateState.ERROR; + } + remoteUrl = fetchedRemoteUrl; + localHash = await localGit.revparse(['HEAD']); } let networkConfig: string[] = []; if (installMetadata.networkPolicy === 'public') { @@ -477,8 +505,11 @@ export async function checkForExtensionUpdate( installMetadata.networkPolicy, ); const refToCheck = installMetadata.ref || 'HEAD'; + const refPatterns = installMetadata.ref + ? [refToCheck, `${refToCheck}^{}`] + : [refToCheck]; - const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]); + const lsRemoteOutput = await git.listRemote([remoteUrl, ...refPatterns]); signal?.throwIfAborted(); if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') { @@ -486,8 +517,12 @@ export async function checkForExtensionUpdate( return ExtensionUpdateState.ERROR; } - const remoteHash = lsRemoteOutput.split('\t')[0]; - const localHash = await git.revparse(['HEAD']); + const remoteLines = lsRemoteOutput.trim().split('\n'); + const peeledLine = remoteLines.find((line) => + line.split('\t')[1]?.endsWith('^{}'), + ); + const remoteLine = peeledLine ?? remoteLines[0]; + const remoteHash = remoteLine?.split('\t')[0]; signal?.throwIfAborted(); if (!remoteHash) { diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts new file mode 100644 index 00000000000..d6ca4faec9d --- /dev/null +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -0,0 +1,570 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { convertCompatibleExtension } from './extension-converter.js'; +import { + convertQoderPlugin, + QODER_PLUGIN_MANIFEST, +} from './qoder-converter.js'; + +describe('convertQoderPlugin', () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-plugin-')); + fs.mkdirSync(path.join(root, '.qoder-plugin'), { recursive: true }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + function writeManifest(config: Record): void { + fs.writeFileSync( + path.join(root, QODER_PLUGIN_MANIFEST), + JSON.stringify(config), + 'utf-8', + ); + } + + it('converts metadata, resources, MCP, and root context files', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + version: '2.0.0', + displayName: 'Sample plugin', + description: 'A synthetic Qoder plugin', + }); + fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + const skillDir = path.join(root, 'skills', 'sample-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + '---\nname: sample-skill\ndescription: Synthetic skill\n---\n', + 'utf-8', + ); + fs.mkdirSync(path.join(root, 'commands'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'commands', 'sample.md'), + '# Sample command', + 'utf-8', + ); + fs.mkdirSync(path.join(root, 'agents'), { recursive: true }); + fs.writeFileSync( + path.join(root, 'agents', 'sample.md'), + '---\nname: sample\ndescription: Synthetic agent\n---\nPrompt', + 'utf-8', + ); + fs.writeFileSync( + path.join(root, 'NOTICE.txt'), + 'Synthetic resource', + 'utf-8', + ); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + + const result = await convertQoderPlugin(root); + + expect(result.config).toMatchObject({ + name: 'sample-qoder-plugin', + version: '2.0.0', + displayName: 'Sample plugin', + description: 'A synthetic Qoder plugin', + contextFileName: ['QWEN.md', 'system-prompt.md'], + }); + expect(result.config.mcpServers?.['sample']).toMatchObject({ + httpUrl: 'https://example.com/mcp', + }); + expect( + fs.existsSync( + path.join(result.convertedDir, 'skills', 'sample-skill', 'SKILL.md'), + ), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'commands', 'sample.md')), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'agents', 'sample.md')), + ).toBe(true); + expect(fs.existsSync(path.join(result.convertedDir, 'NOTICE.txt'))).toBe( + true, + ); + expect(fs.existsSync(path.join(result.convertedDir, '.git'))).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('defaults the version and reports Qoder as the origin', async () => { + writeManifest({ name: 'sample-qoder-plugin' }); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + + const result = await convertCompatibleExtension(root); + + expect(result.originSource).toBe('Qoder'); + const converted = JSON.parse( + fs.readFileSync( + path.join(result.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + expect(converted['version']).toBe('1.0.0'); + expect(converted['contextFileName']).toEqual(['system-prompt.md']); + + fs.rmSync(result.extensionDir, { recursive: true, force: true }); + }); + + it('honors an explicit marketplace selection over a root Qoder manifest', async () => { + writeManifest({ name: 'sample-qoder-plugin', version: '9.9.9' }); + fs.mkdirSync(path.join(root, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(root, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'sample-marketplace', + owner: { name: 'Test Owner', email: 'owner@example.com' }, + plugins: [ + { + name: 'requested-plugin', + version: '2.0.0', + source: './plugin-src', + }, + ], + }), + 'utf-8', + ); + const pluginSourceDir = path.join(root, 'plugin-src'); + fs.mkdirSync(path.join(pluginSourceDir, '.claude-plugin'), { + recursive: true, + }); + fs.writeFileSync( + path.join(pluginSourceDir, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'requested-plugin', version: '2.0.0' }), + 'utf-8', + ); + + const selected = await convertCompatibleExtension(root, 'requested-plugin'); + expect(selected.originSource).toBe('Claude'); + const selectedConfig = JSON.parse( + fs.readFileSync( + path.join(selected.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + expect(selectedConfig['name']).toBe('requested-plugin'); + expect(selectedConfig['version']).toBe('2.0.0'); + fs.rmSync(selected.extensionDir, { recursive: true, force: true }); + + const unselected = await convertCompatibleExtension(root); + expect(unselected.originSource).toBe('Qoder'); + const unselectedConfig = JSON.parse( + fs.readFileSync( + path.join(unselected.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + expect(unselectedConfig['name']).toBe('sample-qoder-plugin'); + expect(unselectedConfig['version']).toBe('9.9.9'); + fs.rmSync(unselected.extensionDir, { recursive: true, force: true }); + }); + + it('omits null optional metadata from the generated config', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + displayName: null, + description: null, + }); + + const result = await convertQoderPlugin(root); + const generated = JSON.parse( + fs.readFileSync( + path.join(result.convertedDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as Record; + + expect(result.config.displayName).toBeUndefined(); + expect(result.config.description).toBeUndefined(); + expect(generated).not.toHaveProperty('displayName'); + expect(generated).not.toHaveProperty('description'); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('merges explicit context with system-prompt.md without duplicates', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + contextFileName: ['custom.md', 42, 'custom.md', './system-prompt.md'], + }); + fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); + fs.writeFileSync(path.join(root, 'custom.md'), '# Custom', 'utf-8'); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toEqual([ + 'QWEN.md', + 'custom.md', + 'system-prompt.md', + ]); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('loads path-valued MCP config from the standard wrapper', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: '.mcp.json', + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(Object.keys(result.config.mcpServers ?? {})).toEqual(['sample']); + expect(result.config.mcpServers?.['sample']).toMatchObject({ + httpUrl: 'https://example.com/mcp', + }); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('prefers inline MCP config over the root MCP file', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: { + inline: { type: 'http', url: 'https://example.com/inline' }, + }, + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + root: { type: 'http', url: 'https://example.com/root' }, + }, + }), + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(Object.keys(result.config.mcpServers ?? {})).toEqual(['inline']); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('treats null mcpServers as absent and falls back to the root MCP file', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: null, + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(Object.keys(result.config.mcpServers ?? {})).toEqual(['sample']); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('treats null mcpServers without a root MCP file as absent', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: null, + }); + + const result = await convertQoderPlugin(root); + + expect(result.config.mcpServers).toBeUndefined(); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('rejects malformed root MCP config', async () => { + writeManifest({ name: 'sample-qoder-plugin' }); + fs.writeFileSync(path.join(root, '.mcp.json'), '\u001b[31m{', 'utf-8'); + + const error = await convertQoderPlugin(root).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('\u001b'); + expect((error as Error).message).toMatch(/Invalid Qoder MCP configuration/); + }); + + it.skipIf(process.platform === 'win32').each([ + ['JSON value', 'null', /expected a JSON object/], + [ + 'wrapper', + JSON.stringify({ mcpServers: null }), + /expected an "mcpServers" object/, + ], + [ + 'server entry', + JSON.stringify({ mcpServers: { invalid: null } }), + /server entries must be JSON objects/, + ], + ])( + 'sanitizes control sequences in MCP %s errors', + async (_case, body, errorPattern) => { + const mcpFile = 'mcp\u001b[31m.json'; + writeManifest({ name: 'sample-qoder-plugin', mcpServers: mcpFile }); + fs.writeFileSync(path.join(root, mcpFile), body, 'utf-8'); + + const error = await convertQoderPlugin(root).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('\u001b'); + expect((error as Error).message).toMatch(errorPattern); + }, + ); + + it('rejects an invalid MCP wrapper from a configured path', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + mcpServers: '.mcp.json', + }); + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ mcpServers: null }), + 'utf-8', + ); + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /expected an "mcpServers" object/, + ); + }); + + it.each(['inline', 'root'] as const)( + 'rejects non-object MCP server entries from %s config', + async (source) => { + writeManifest({ + name: 'sample-qoder-plugin', + ...(source === 'inline' + ? { mcpServers: { invalid: null } } + : undefined), + }); + if (source === 'root') { + fs.writeFileSync( + path.join(root, '.mcp.json'), + JSON.stringify({ mcpServers: { invalid: null } }), + 'utf-8', + ); + } + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /server entries must be JSON objects/, + ); + }, + ); + + it('does not load an escaping root MCP symlink', async () => { + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalMcp = path.join(external, '.mcp.json'); + fs.writeFileSync( + externalMcp, + JSON.stringify({ + mcpServers: { + sample: { type: 'http', url: 'https://example.com/mcp' }, + }, + }), + 'utf-8', + ); + writeManifest({ name: 'sample-qoder-plugin' }); + fs.symlinkSync(externalMcp, path.join(root, '.mcp.json')); + + const result = await convertQoderPlugin(root); + + expect(result.config.mcpServers).toBeUndefined(); + expect(fs.existsSync(path.join(result.convertedDir, '.mcp.json'))).toBe( + false, + ); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }); + + it('loads QWEN.md with system-prompt.md when context is not configured', async () => { + writeManifest({ name: 'sample-qoder-plugin', contextFileName: [] }); + fs.writeFileSync(path.join(root, 'QWEN.md'), '# Qwen context', 'utf-8'); + fs.writeFileSync( + path.join(root, 'system-prompt.md'), + '# System context', + 'utf-8', + ); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toEqual([ + 'QWEN.md', + 'system-prompt.md', + ]); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('rejects invalid manifests and escaping manifest symlinks', async () => { + fs.writeFileSync(path.join(root, QODER_PLUGIN_MANIFEST), 'null', 'utf-8'); + await expect(convertQoderPlugin(root)).rejects.toThrow( + /expected a JSON object/, + ); + + writeManifest({}); + await expect(convertQoderPlugin(root)).rejects.toThrow( + /must have name field/, + ); + + writeManifest({ name: 123 }); + await expect(convertQoderPlugin(root)).rejects.toThrow( + /must have name field/, + ); + + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalManifest = path.join(external, 'plugin.json'); + fs.writeFileSync( + externalManifest, + JSON.stringify({ name: 'external-plugin' }), + 'utf-8', + ); + fs.rmSync(path.join(root, QODER_PLUGIN_MANIFEST)); + fs.symlinkSync(externalManifest, path.join(root, QODER_PLUGIN_MANIFEST)); + + await expect(convertQoderPlugin(root)).rejects.toThrow( + /resolves through a symlink outside/, + ); + fs.rmSync(external, { recursive: true, force: true }); + }); + + it('sanitizes control sequences from manifest parse errors', async () => { + fs.writeFileSync( + path.join(root, QODER_PLUGIN_MANIFEST), + '\u001b[31minvalid', + 'utf-8', + ); + + const error = await convertQoderPlugin(root).catch( + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).not.toContain('\u001b'); + expect((error as Error).message).toMatch( + /Invalid Qoder plugin configuration/, + ); + }); + + it('does not copy escaping symlinks or load unsafe context paths', async () => { + const external = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-external-')); + const externalFile = path.join(external, 'private.txt'); + fs.writeFileSync(externalFile, 'private', 'utf-8'); + writeManifest({ + name: 'sample-qoder-plugin', + contextFileName: 'leak.md', + }); + fs.symlinkSync(externalFile, path.join(root, 'leak.md')); + fs.mkdirSync(path.join(root, 'skills'), { recursive: true }); + fs.symlinkSync(externalFile, path.join(root, 'skills', 'leak.txt')); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect( + fs.existsSync(path.join(result.convertedDir, 'skills', 'leak.txt')), + ).toBe(false); + + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }); + + it.each(['QWEN.md', 'system-prompt.md'])( + 'does not load an escaping default %s symlink', + async (contextFile) => { + const external = fs.mkdtempSync( + path.join(os.tmpdir(), 'qoder-external-'), + ); + const externalFile = path.join(external, contextFile); + fs.writeFileSync(externalFile, 'External context', 'utf-8'); + writeManifest({ name: 'sample-qoder-plugin' }); + fs.symlinkSync(externalFile, path.join(root, contextFile)); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect(fs.existsSync(path.join(result.convertedDir, contextFile))).toBe( + false, + ); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + fs.rmSync(external, { recursive: true, force: true }); + }, + ); + + it('drops context files removed during selective resource collection', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + commands: 'commands/kept.md', + contextFileName: 'commands/removed.md', + }); + fs.mkdirSync(path.join(root, 'commands'), { recursive: true }); + fs.writeFileSync(path.join(root, 'commands', 'kept.md'), '# Kept'); + fs.writeFileSync(path.join(root, 'commands', 'removed.md'), '# Removed'); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + expect( + fs.existsSync(path.join(result.convertedDir, 'commands', 'kept.md')), + ).toBe(true); + expect( + fs.existsSync(path.join(result.convertedDir, 'commands', 'removed.md')), + ).toBe(false); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('ignores context paths that resolve to directories', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + contextFileName: 'docs', + }); + fs.mkdirSync(path.join(root, 'docs')); + + const result = await convertQoderPlugin(root); + + expect(result.config.contextFileName).toBeUndefined(); + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); +}); diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts new file mode 100644 index 00000000000..fb5db93bc39 --- /dev/null +++ b/packages/core/src/extension/qoder-converter.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { MCPServerConfig } from '../config/config.js'; +import type { ExtensionConfig } from './extensionManager.js'; +import { + buildQwenExtensionFromPlugin, + normalizeClaudeMcpServer, + resolvePluginRelativeFile, + type ClaudePluginConfig, +} from './claude-converter.js'; +import { realPathWithin } from './gemini-converter.js'; +import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; +import { stripAnsiAndControl } from '../utils/textUtils.js'; + +export const QODER_PLUGIN_MANIFEST = '.qoder-plugin/plugin.json'; + +type QoderPluginConfig = Omit & { + version?: string; + displayName?: string; + contextFileName?: string | string[]; +}; + +function normalizeMcpServers( + servers: Record, + configPath: string, +): Record { + return Object.fromEntries( + Object.entries(servers).map(([name, server]) => { + if ( + typeof server !== 'object' || + server === null || + Array.isArray(server) + ) { + throw new Error( + `Invalid Qoder MCP configuration at ${configPath}: server entries must be JSON objects`, + ); + } + return [name, normalizeClaudeMcpServer(server)]; + }), + ); +} + +function loadQoderConfig(extensionDir: string): QoderPluginConfig { + const configPath = path.join(extensionDir, QODER_PLUGIN_MANIFEST); + if (!fs.existsSync(configPath)) { + throw new Error(`Qoder plugin configuration not found at ${configPath}`); + } + if (!realPathWithin(configPath, extensionDir)) { + throw new Error( + `Qoder plugin configuration at ${configPath} resolves through a symlink outside the plugin`, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } catch (error) { + throw new Error( + stripAnsiAndControl( + `Invalid Qoder plugin configuration at ${configPath}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `Invalid Qoder plugin configuration at ${configPath}: expected a JSON object`, + ); + } + + const config = parsed as QoderPluginConfig; + if (typeof config.name !== 'string' || config.name.length === 0) { + throw new Error('Qoder plugin config must have name field'); + } + return { + ...config, + version: + typeof config.version === 'string' && config.version.length > 0 + ? config.version + : '1.0.0', + displayName: + typeof config.displayName === 'string' ? config.displayName : undefined, + description: + typeof config.description === 'string' ? config.description : undefined, + }; +} + +function loadMcpServersFile( + extensionDir: string, + relativePath: string, + requireWrapper: boolean, +): Record | undefined { + const mcpPath = resolvePluginRelativeFile(extensionDir, relativePath); + if (!mcpPath || !fs.existsSync(mcpPath)) { + return undefined; + } + const safeMcpPath = stripAnsiAndControl(mcpPath); + + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(mcpPath, 'utf-8')); + } catch (error) { + throw new Error( + stripAnsiAndControl( + `Invalid Qoder MCP configuration at ${safeMcpPath}: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error( + `Invalid Qoder MCP configuration at ${safeMcpPath}: expected a JSON object`, + ); + } + const hasWrapper = Object.prototype.hasOwnProperty.call(parsed, 'mcpServers'); + const servers = hasWrapper + ? (parsed as { mcpServers?: unknown }).mcpServers + : requireWrapper + ? undefined + : parsed; + if ( + typeof servers !== 'object' || + servers === null || + Array.isArray(servers) + ) { + throw new Error( + `Invalid Qoder MCP configuration at ${safeMcpPath}: expected an "mcpServers" object`, + ); + } + + return normalizeMcpServers( + servers as Record, + safeMcpPath, + ); +} + +function resolveMcpServers( + extensionDir: string, + configured: QoderPluginConfig['mcpServers'], +): Record | undefined { + if (typeof configured === 'string') { + return loadMcpServersFile(extensionDir, configured, false); + } + if (configured !== undefined && configured !== null) { + if (typeof configured !== 'object' || Array.isArray(configured)) { + throw new Error('Qoder plugin mcpServers must be an object or file path'); + } + return normalizeMcpServers( + configured, + path.join(extensionDir, QODER_PLUGIN_MANIFEST), + ); + } + return loadMcpServersFile(extensionDir, '.mcp.json', true); +} + +function resolveContextFiles( + extensionDir: string, + configured: string | string[] | undefined, +): string[] | undefined { + const configuredFiles = configured + ? Array.isArray(configured) + ? configured + : [configured] + : []; + const contextFiles: string[] = []; + const seen = new Set(); + const addContextFile = (relativePath: string, prepend = false): void => { + const resolved = resolvePluginRelativeFile(extensionDir, relativePath); + if (!resolved) return; + try { + if (!fs.statSync(resolved).isFile()) return; + } catch { + return; + } + const normalized = path.relative(path.resolve(extensionDir), resolved); + if (normalized && !seen.has(normalized)) { + seen.add(normalized); + if (prepend) contextFiles.unshift(normalized); + else contextFiles.push(normalized); + } + }; + + for (const file of configuredFiles) { + if (typeof file === 'string') addContextFile(file); + } + addContextFile('system-prompt.md'); + if (contextFiles.length > 0) { + addContextFile('QWEN.md', true); + } + return contextFiles.length > 0 ? contextFiles : undefined; +} + +export async function convertQoderPlugin( + extensionDir: string, +): Promise<{ config: ExtensionConfig; convertedDir: string }> { + const config = loadQoderConfig(extensionDir); + config.mcpServers = resolveMcpServers(extensionDir, config.mcpServers); + const converted = await buildQwenExtensionFromPlugin( + extensionDir, + config as ClaudePluginConfig, + ); + const contextFileName = resolveContextFiles( + converted.convertedDir, + config.contextFileName, + ); + const qwenConfig: ExtensionConfig = { + ...converted.config, + displayName: config.displayName, + contextFileName, + }; + try { + fs.writeFileSync( + path.join(converted.convertedDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(qwenConfig, null, 2), + 'utf-8', + ); + } catch (error) { + fs.rmSync(converted.convertedDir, { recursive: true, force: true }); + throw error; + } + return { ...converted, config: qwenConfig }; +} diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index d2ec3803580..721dee1bc3d 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -3736,7 +3736,11 @@ export type DaemonExtensionInstallType = | 'github-release' | 'npm'; -export type DaemonExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini'; +export type DaemonExtensionOriginSource = + | 'QwenCode' + | 'Claude' + | 'Gemini' + | 'Qoder'; export interface DaemonExtensionCapabilities { mcpServerCount: number;