diff --git a/.qwen/e2e-tests/8539-dual-manifest-extension-hooks.md b/.qwen/e2e-tests/8539-dual-manifest-extension-hooks.md new file mode 100644 index 00000000000..1c0d97da57e --- /dev/null +++ b/.qwen/e2e-tests/8539-dual-manifest-extension-hooks.md @@ -0,0 +1,18 @@ +# Dual-manifest extension hooks reviewer plan + +## Scope + +Verify a trusted test extension containing `gemini-extension.json`, `.claude-plugin/plugin.json`, a Claude hooks file, `AGENTS.md`, and a Gemini TOML slash command. Do not run third-party Ponytail hook code for this review. + +## How to verify + +1. Build Qwen Code from this branch. +2. Create a local fixture extension whose Gemini manifest declares `contextFileName: "AGENTS.md"`, whose Claude manifest points to a hooks JSON file, and whose `commands/` directory contains a valid TOML command. +3. Make the fixture's `SessionStart` hook write a fixed marker to a temporary test directory. Install the fixture once as a standalone extension and once through a local Claude marketplace entry. +4. Remove or move the original fixture source after installation, then start a fresh session for each installed form. +5. Confirm the marker is written by the hook from the installed extension path, the `AGENTS.md` instruction is present in the session context, and the converted slash command is listed and can be invoked. +6. Uninstall both fixture extensions and remove the temporary marker directory. + +## Expected result + +Both install forms retain the Gemini context and TOML command while registering the Claude hook. The hook command resolves `${CLAUDE_PLUGIN_ROOT}` to the final installed extension directory and does not depend on the original source or conversion directory. diff --git a/docs/users/features/hooks.md b/docs/users/features/hooks.md index 96884859155..10d0ab2a552 100644 --- a/docs/users/features/hooks.md +++ b/docs/users/features/hooks.md @@ -28,6 +28,12 @@ Hooks are user-defined scripts or programs that are automatically executed by Qw - Integrate with external systems and services - Modify tool inputs or responses programmatically +### Hooks provided by extensions + +Extensions can provide hooks inline in `qwen-extension.json` or reference a hooks JSON file from the manifest. Claude-compatible extensions can likewise declare an inline `hooks` object or a relative hooks file in `.claude-plugin/plugin.json`. + +For extensions that contain both `gemini-extension.json` and a root `.claude-plugin/plugin.json`, Qwen Code keeps the Gemini extension resources, context file, and settings while also importing the Claude-compatible hooks for that root. For a named marketplace install, this merge happens only when the selected marketplace entry points to the repository root; fields on that entry still override `plugin.json` as usual. `${CLAUDE_PLUGIN_ROOT}` in imported hooks resolves to the installed extension directory. Install third-party extensions only from sources you trust because command hooks execute with your user permissions. + ## Hook Types Qwen Code supports four hook executor types: diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 241f9500971..25ded55e37c 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -520,13 +520,15 @@ export function registerWorkspaceExtensionRoutes( plugins: marketplace.plugins.map((plugin) => ({ name: plugin.name, ...(plugin.description ? { description: plugin.description } : {}), - source: redactExtensionDisplaySource( - typeof plugin.source === 'string' - ? plugin.source - : plugin.source.source === 'github' - ? plugin.source.repo - : plugin.source.url, - ), + source: plugin.source + ? redactExtensionDisplaySource( + typeof plugin.source === 'string' + ? plugin.source + : plugin.source.source === 'github' + ? plugin.source.repo + : plugin.source.url, + ) + : '.', ...(plugin.category ? { category: plugin.category } : {}), ...(plugin.tags ? { tags: plugin.tags } : {}), })), diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 0aa05c9261f..f56b93cac0b 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -6238,7 +6238,8 @@ describe('createServeApp', () => { owner: { name: string; email: string }; plugins: Array<{ name: string; - source: string; + source?: string; + description?: string; category?: string; tags?: string[]; }>; @@ -6255,6 +6256,10 @@ describe('createServeApp', () => { category: 'tools', tags: ['example'], }, + { + name: 'root-plugin', + description: 'Plugin at the marketplace root', + }, ], }); return testExtension('example-plugin'); @@ -6297,6 +6302,11 @@ describe('createServeApp', () => { category: 'tools', tags: ['example'], }, + { + name: 'root-plugin', + description: 'Plugin at the marketplace root', + source: '.', + }, ], }, }); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx index 53afe8a30a6..19b7c686e06 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.test.tsx @@ -59,6 +59,7 @@ describe('DiscoverTab', () => { name: 'demo', marketplaceName: 'market', installSource: 'owner/demo', + pluginSourceKind: 'extension-root', installed: false, } as DiscoveredPlugin; const manager = { @@ -94,6 +95,9 @@ describe('DiscoverTab', () => { }); await waitFor(() => expect(manager.installExtension).toHaveBeenCalled()); + expect(mockParseInstallSource).toHaveBeenCalledWith('owner/demo', { + pluginSourceKind: 'extension-root', + }); expect(manager.installExtension).toHaveBeenCalledWith( { type: 'git', source: 'owner/demo' }, undefined, @@ -150,4 +154,52 @@ describe('DiscoverTab', () => { }), ); }); + + it('rejects a direct-JSON entry that has no installable source', async () => { + const plugin = { + name: 'missing-source', + marketplaceName: 'market', + installSource: '', + pluginSourceKind: 'extension-root', + installed: false, + } as DiscoveredPlugin; + const manager = { + discoverPlugins: vi.fn().mockResolvedValue([plugin]), + installExtension: vi.fn(), + setExtensionScope: vi.fn(), + }; + const onStatus = vi.fn(); + + render( + manager } as unknown as Config} + isActive + onLockChange={vi.fn()} + onStatus={onStatus} + onInstalled={vi.fn()} + reloadSignal={0} + />, + ); + await waitFor(() => expect(manager.discoverPlugins).toHaveBeenCalled()); + + await act(async () => { + activeKeypress()({ name: 'return' } as Key); + }); + const detailSelect = mockRadioButtonSelect.mock.calls.at(-1)?.[0] as + | SelectProps<'project'> + | undefined; + await act(async () => { + detailSelect?.onSelect('project'); + }); + + await waitFor(() => + expect(onStatus).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('no installable source'), + }), + ), + ); + expect(mockParseInstallSource).not.toHaveBeenCalled(); + expect(manager.installExtension).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx index 546588d8cb0..164e32c8b55 100644 --- a/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx +++ b/packages/cli/src/ui/components/extensions/tabs/DiscoverTab.tsx @@ -219,7 +219,16 @@ export const DiscoverTab = ({ for (const plugin of targets) { let ext; try { - const metadata = await parseInstallSource(plugin.installSource); + if (!plugin.installSource) { + throw new Error( + t( + 'This marketplace entry has no installable source. Add a Git, archive, or repository source to its marketplace metadata.', + ), + ); + } + const metadata = await parseInstallSource(plugin.installSource, { + pluginSourceKind: plugin.pluginSourceKind, + }); ext = await extensionManager.installExtension( metadata, undefined, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 10f144734ac..47f6440322e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -697,6 +697,7 @@ export type ExtensionOriginSource = | 'Qoder' | 'AgentPlugins'; export type ExtensionNetworkPolicy = 'public'; +export type ExtensionPluginSourceKind = 'marketplace-entry' | 'extension-root'; export interface ExtensionInstallMetadata { source: string; @@ -720,6 +721,7 @@ export interface ExtensionInstallMetadata { allowPreRelease?: boolean; marketplaceConfig?: ClaudeMarketplaceConfig; pluginName?: string; + pluginSourceKind?: ExtensionPluginSourceKind; networkPolicy?: ExtensionNetworkPolicy; } diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 1d2f499d9a2..bd64939b4a7 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -23,6 +23,7 @@ import { import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; import { HookType } from '../hooks/types.js'; import { performVariableReplacement } from './variables.js'; +import { ExtensionStorage } from './storage.js'; // The git-subdir source clones a repo; stub the network clone so the security // guards around the cloned subdirectory can be exercised against a real fs. @@ -228,6 +229,116 @@ describe('convertClaudePluginPackage', () => { } }); + it('cleans the temporary plugin staging directory after conversion', async () => { + const pluginSourceDir = path.join(testDir, 'root-plugin'); + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify({ + name: 'test-marketplace', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'root-plugin', + version: '1.0.0', + source: './', + }, + ], + }), + 'utf-8', + ); + + const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage); + const tempDirs: string[] = []; + const createTmpDirSpy = vi + .spyOn(ExtensionStorage, 'createTmpDir') + .mockImplementation(async () => { + const tempDir = await createTmpDir(); + tempDirs.push(tempDir); + return tempDir; + }); + let outputDir: string | undefined; + + try { + const result = await convertClaudePluginPackage( + pluginSourceDir, + 'root-plugin', + ); + outputDir = result.convertedDir; + + expect(tempDirs).toHaveLength(2); + expect(outputDir).toBe(tempDirs[1]); + expect(fs.existsSync(tempDirs[0])).toBe(false); + expect(fs.existsSync(outputDir)).toBe(true); + } finally { + createTmpDirSpy.mockRestore(); + for (const tempDir of tempDirs) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + }); + + it('treats a marketplace entry without source as the marketplace root', async () => { + const marketplaceDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify({ + name: 'root-marketplace', + owner: { name: 'Owner' }, + plugins: [{ name: 'root-plugin', version: '1.0.0' }], + }), + ); + + const result = await convertClaudePluginPackage(testDir, 'root-plugin'); + try { + expect(result.config.name).toBe('root-plugin'); + expect(result.config.version).toBe('1.0.0'); + } finally { + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + } + }); + + it('cleans the marketplace staging directory when conversion fails', async () => { + const marketplaceDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify({ + name: 'strict-marketplace', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'strict-root', + version: '1.0.0', + source: './', + strict: true, + }, + ], + }), + ); + const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage); + const tempDirs: string[] = []; + const createTmpDirSpy = vi + .spyOn(ExtensionStorage, 'createTmpDir') + .mockImplementation(async () => { + const tempDir = await createTmpDir(); + tempDirs.push(tempDir); + return tempDir; + }); + + try { + await expect( + convertClaudePluginPackage(testDir, 'strict-root'), + ).rejects.toThrow('Strict mode requires plugin.json'); + expect(tempDirs).toHaveLength(1); + expect(fs.existsSync(tempDirs[0])).toBe(false); + } finally { + createTmpDirSpy.mockRestore(); + } + }); + it('should only collect specified skills when config provides explicit list', async () => { // Setup: Create a plugin source with multiple skills const pluginSourceDir = path.join(testDir, 'plugin-source'); @@ -875,7 +986,8 @@ describe('convertClaudePluginPackage', () => { expect( (result.config.hooks!['PostToolUse']![0].hooks![0] as { command: string }) .command, - ).toBe(`${pluginSourceDir}/scripts/post-install.sh`); + ).toBe(`${result.convertedDir}/scripts/post-install.sh`); + expect(fs.existsSync(result.convertedDir)).toBe(true); // Clean up converted directory fs.rmSync(result.convertedDir, { recursive: true, force: true }); @@ -1069,6 +1181,51 @@ describe('convertClaudePluginStandalone', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('stops a standalone conversion when its recursive copy is aborted', async () => { + const pluginDir = path.join(testDir, '.claude-plugin'); + fs.mkdirSync(pluginDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginDir, 'plugin.json'), + JSON.stringify({ name: 'abort-copy', version: '1.0.0' }), + 'utf-8', + ); + const sourceDir = path.join(testDir, 'assets'); + fs.mkdirSync(sourceDir, { recursive: true }); + fs.writeFileSync(path.join(sourceDir, 'one.txt'), 'one', 'utf-8'); + fs.writeFileSync(path.join(sourceDir, 'two.txt'), 'two', 'utf-8'); + + const controller = new AbortController(); + const reason = new Error('conversion cancelled'); + const copyFile = fs.promises.copyFile.bind(fs.promises); + const copySpy = vi + .spyOn(fs.promises, 'copyFile') + .mockImplementation(async (...args) => { + await copyFile(...args); + controller.abort(reason); + }); + const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage); + const tempDirs: string[] = []; + const createTmpDirSpy = vi + .spyOn(ExtensionStorage, 'createTmpDir') + .mockImplementation(async () => { + const tempDir = await createTmpDir(); + tempDirs.push(tempDir); + return tempDir; + }); + + try { + await expect( + convertClaudePluginStandalone(testDir, false, controller.signal), + ).rejects.toBe(reason); + expect(copySpy).toHaveBeenCalledOnce(); + expect(tempDirs).toHaveLength(1); + expect(fs.existsSync(tempDirs[0])).toBe(false); + } finally { + copySpy.mockRestore(); + createTmpDirSpy.mockRestore(); + } + }); + it('throws when there is no .claude-plugin/plugin.json', async () => { await expect(convertClaudePluginStandalone(testDir)).rejects.toThrow( /Plugin configuration not found/, diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index afef729db4a..d13b750d852 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -18,7 +18,6 @@ import type { } from '../config/config.js'; import type { HookEventName, HookDefinition } from '../hooks/types.js'; import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; -import { createHash } from 'node:crypto'; import { copyDirectory, isPathWithin, @@ -103,7 +102,7 @@ export type ClaudePluginSource = }; export interface ClaudeMarketplacePluginConfig extends ClaudePluginConfig { - source: string | ClaudePluginSource; + source?: string | ClaudePluginSource; category?: string; strict?: boolean; tags?: string[]; @@ -250,7 +249,11 @@ export function convertClaudeAgentConfig( * Parses the YAML frontmatter, converts the configuration, and writes back. * @param agentsDir Directory containing agent markdown files */ -async function convertAgentFiles(agentsDir: string): Promise { +async function convertAgentFiles( + agentsDir: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); if (!fs.existsSync(agentsDir)) { return; } @@ -258,6 +261,7 @@ async function convertAgentFiles(agentsDir: string): Promise { const files = await fs.promises.readdir(agentsDir); for (const file of files) { + signal?.throwIfAborted(); if (!file.endsWith('.md')) continue; const filePath = path.join(agentsDir, file); @@ -323,6 +327,7 @@ ${systemPrompt} await fs.promises.writeFile(filePath, newContent, 'utf-8'); } catch (error) { + signal?.throwIfAborted(); debugLogger.warn( `[Claude Converter] Failed to convert agent file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, ); @@ -453,6 +458,7 @@ export async function convertClaudePluginPackage( pluginName: string, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], signal?: AbortSignal, + preserveHookVariables = false, ): Promise<{ config: ExtensionConfig; convertedDir: string; @@ -491,68 +497,72 @@ export async function convertClaudePluginPackage( } // Step 2: Resolve plugin source directory based on source field - const pluginDir = path.join( - extensionDir, - `plugin${createHash('sha256').update(`${extensionDir}/${pluginName}`).digest('hex')}`, - ); - await fs.promises.mkdir(pluginDir, { recursive: true }); - - const { pluginSource, externalContent } = await resolvePluginSource( - marketplacePlugin, - extensionDir, - pluginDir, - networkPolicy, - signal, - ); + const pluginDir = await ExtensionStorage.createTmpDir(); + try { + const { pluginSource, externalContent } = await resolvePluginSource( + marketplacePlugin, + extensionDir, + pluginDir, + networkPolicy, + signal, + ); - if (!fs.existsSync(pluginSource)) { - throw new Error(`Plugin source directory not found: ${pluginSource}`); - } + if (!fs.existsSync(pluginSource)) { + throw new Error(`Plugin source directory not found: ${pluginSource}`); + } - // Step 3: Load and merge plugin.json if exists (based on strict mode) - const strict = marketplacePlugin.strict ?? false; - let mergedConfig: ClaudePluginConfig; + // Step 3: Load and merge plugin.json if exists (based on strict mode) + const strict = marketplacePlugin.strict ?? false; + let mergedConfig: ClaudePluginConfig; - const pluginJsonPath = path.join( - pluginSource, - '.claude-plugin', - 'plugin.json', - ); - if (strict && !fs.existsSync(pluginJsonPath)) { - throw new Error(`Strict mode requires plugin.json at ${pluginJsonPath}`); - } - // Treat a symlinked plugin.json (pointing outside the source) as absent - // rather than reading an arbitrary host file into the merged config. - const pluginJsonSafe = - fs.existsSync(pluginJsonPath) && - realPathWithin(pluginJsonPath, pluginSource); - if (pluginJsonSafe) { - const pluginContent = fs.readFileSync(pluginJsonPath, 'utf-8'); - const pluginConfig: ClaudePluginConfig = JSON.parse(pluginContent); - mergedConfig = mergeClaudeConfigs(marketplacePlugin, pluginConfig); - } else { - // `existsSync` follows symlinks, so the strict check at line 500 passes - // when plugin.json is a symlink to an existing host file — but the file is - // not trusted (`realPathWithin` rejected it). Strict mode must fail here - // rather than silently fall back to the marketplace entry. - if (strict) { - throw new Error( - `Strict mode requires a trusted plugin.json at ${pluginJsonPath}`, - ); + const pluginJsonPath = path.join( + pluginSource, + '.claude-plugin', + 'plugin.json', + ); + if (strict && !fs.existsSync(pluginJsonPath)) { + throw new Error(`Strict mode requires plugin.json at ${pluginJsonPath}`); } - if (fs.existsSync(pluginJsonPath)) { - debugLogger.warn( - `Ignoring plugin.json at ${pluginJsonPath}; it resolves through a symlink outside the plugin.`, - ); + // Treat a symlinked plugin.json (pointing outside the source) as absent + // rather than reading an arbitrary host file into the merged config. + const pluginJsonSafe = + fs.existsSync(pluginJsonPath) && + realPathWithin(pluginJsonPath, pluginSource); + if (pluginJsonSafe) { + const pluginContent = fs.readFileSync(pluginJsonPath, 'utf-8'); + const pluginConfig: ClaudePluginConfig = JSON.parse(pluginContent); + mergedConfig = mergeClaudeConfigs(marketplacePlugin, pluginConfig); + } else { + // `existsSync` follows symlinks, so the strict check above passes when + // plugin.json points to an existing host file. Strict mode must fail + // here rather than silently fall back to the marketplace entry. + if (strict) { + throw new Error( + `Strict mode requires a trusted plugin.json at ${pluginJsonPath}`, + ); + } + if (fs.existsSync(pluginJsonPath)) { + debugLogger.warn( + `Ignoring plugin.json at ${pluginJsonPath}; it resolves through a symlink outside the plugin.`, + ); + } + mergedConfig = marketplacePlugin as ClaudePluginConfig; } - mergedConfig = marketplacePlugin as ClaudePluginConfig; - } - const converted = await buildQwenExtensionFromPlugin( - pluginSource, - mergedConfig, - ); - return { ...converted, externalContent }; + const converted = await buildQwenExtensionFromPlugin( + pluginSource, + mergedConfig, + preserveHookVariables, + signal, + ); + return { ...converted, externalContent }; + } finally { + try { + await fs.promises.rm(pluginDir, { recursive: true, force: true }); + } catch { + // Best-effort cleanup must not mask conversion errors or a valid result. + } + } } /** @@ -593,6 +603,90 @@ export function resolvePluginRelativeFile( return resolved; } +type ClaudeHooks = { [K in HookEventName]?: HookDefinition[] }; + +function loadClaudePluginManifest(pluginSource: string): ClaudePluginConfig { + const pluginJsonPath = path.join( + pluginSource, + '.claude-plugin', + 'plugin.json', + ); + if (!fs.existsSync(pluginJsonPath)) { + throw new Error(`Plugin configuration not found at ${pluginJsonPath}`); + } + if (!realPathWithin(pluginJsonPath, pluginSource)) { + throw new Error( + `Plugin configuration at ${pluginJsonPath} resolves through a symlink outside the plugin`, + ); + } + + const parsedConfig: unknown = JSON.parse( + fs.readFileSync(pluginJsonPath, 'utf-8'), + ); + if ( + typeof parsedConfig !== 'object' || + parsedConfig === null || + Array.isArray(parsedConfig) + ) { + throw new Error( + `Invalid plugin configuration at ${pluginJsonPath}: expected a JSON object`, + ); + } + return parsedConfig as ClaudePluginConfig; +} + +function loadClaudeHooks( + pluginSource: string, + hooks: ClaudePluginConfig['hooks'], +): ClaudeHooks | undefined { + if (!hooks) return undefined; + if (typeof hooks !== 'string') return hooks; + + const hooksPath = resolvePluginRelativeFile(pluginSource, hooks); + if (!hooksPath || !fs.existsSync(hooksPath)) return undefined; + + try { + const parsedHooks: unknown = JSON.parse( + fs.readFileSync(hooksPath, 'utf-8'), + ); + if (typeof parsedHooks !== 'object' || parsedHooks === null) { + return undefined; + } + const hooksData = + 'hooks' in parsedHooks + ? (parsedHooks as { hooks?: unknown }).hooks + : parsedHooks; + return typeof hooksData === 'object' && hooksData !== null + ? (hooksData as ClaudeHooks) + : undefined; + } catch (error) { + debugLogger.warn( + `Failed to parse hooks file ${hooksPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + return undefined; + } +} + +/** + * Loads only the Claude hook metadata needed when a sibling Gemini manifest + * owns the installed artifact. This avoids creating and deleting a second full + * converted copy of a dual-manifest repository merely to read its hooks. + */ +export function loadClaudePluginHooks( + pluginSource: string, + marketplacePlugin?: ClaudeMarketplacePluginConfig, + signal?: AbortSignal, +): ClaudeHooks | undefined { + signal?.throwIfAborted(); + const pluginConfig = loadClaudePluginManifest(pluginSource); + const mergedConfig = marketplacePlugin + ? mergeClaudeConfigs(marketplacePlugin, pluginConfig) + : pluginConfig; + const hooks = loadClaudeHooks(pluginSource, mergedConfig.hooks); + signal?.throwIfAborted(); + return hooks; +} + /** * Builds a converted Qwen extension directory from a resolved Claude plugin * source directory and its merged config. Shared by the marketplace-based @@ -602,7 +696,10 @@ export function resolvePluginRelativeFile( export async function buildQwenExtensionFromPlugin( pluginSource: string, mergedConfig: ClaudePluginConfig, + preserveHookVariables = false, + signal?: AbortSignal, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { + signal?.throwIfAborted(); // Resolve MCP servers from a JSON file path if needed. if (mergedConfig.mcpServers && typeof mergedConfig.mcpServers === 'string') { const mcpServersPath = resolvePluginRelativeFile( @@ -628,7 +725,7 @@ export async function buildQwenExtensionFromPlugin( const tmpDir = await ExtensionStorage.createTmpDir(); try { - await copyDirectory(pluginSource, tmpDir); + await copyDirectory(pluginSource, tmpDir, undefined, signal); // A standalone plugin's source is a full git clone; drop VCS metadata so // it isn't shipped into the installed extension. @@ -646,6 +743,7 @@ export async function buildQwenExtensionFromPlugin( ]; for (const { name, config } of resourceConfigs) { + signal?.throwIfAborted(); const folderPath = path.join(tmpDir, name); const sourceFolderPath = path.join(pluginSource, name); @@ -653,7 +751,7 @@ export async function buildQwenExtensionFromPlugin( if (fs.existsSync(folderPath)) { fs.rmSync(folderPath, { recursive: true, force: true }); } - await collectResources(config, pluginSource, folderPath); + await collectResources(config, pluginSource, folderPath, signal); } else if ( !fs.existsSync(sourceFolderPath) && fs.existsSync(folderPath) @@ -662,44 +760,25 @@ export async function buildQwenExtensionFromPlugin( } } - // Handle hooks from a file path if needed. - if (mergedConfig.hooks && typeof mergedConfig.hooks === 'string') { - const hooksPath = resolvePluginRelativeFile( - pluginSource, - mergedConfig.hooks, - ); - - if (hooksPath && fs.existsSync(hooksPath)) { - try { - const hooksContent = fs.readFileSync(hooksPath, 'utf-8'); - const parsedHooks = JSON.parse(hooksContent); - - let hooksData; - if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') { - hooksData = parsedHooks.hooks as { - [K in HookEventName]?: HookDefinition[]; - }; - } else { - hooksData = parsedHooks as { - [K in HookEventName]?: HookDefinition[]; - }; - } - - mergedConfig.hooks = substituteHookVariables(hooksData, pluginSource); - } catch (error) { - debugLogger.warn( - `Failed to parse hooks file ${hooksPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } + // Resolve file-based hooks, then either preserve the variable for the + // installer or bind it to the converted directory that actually survives + // this call. Never bind a public result to a source staging directory that + // convertClaudePluginPackage deletes in its finally block. + const resolvedHooks = loadClaudeHooks(pluginSource, mergedConfig.hooks); + if (resolvedHooks) { + mergedConfig.hooks = preserveHookVariables + ? resolvedHooks + : substituteHookVariables(resolvedHooks, tmpDir); } const agentsDestDir = path.join(tmpDir, 'agents'); - await convertAgentFiles(agentsDestDir); + await convertAgentFiles(agentsDestDir, signal); + signal?.throwIfAborted(); const qwenConfig = convertClaudeToQwenConfig(mergedConfig); const qwenConfigPath = path.join(tmpDir, 'qwen-extension.json'); + signal?.throwIfAborted(); fs.writeFileSync( qwenConfigPath, JSON.stringify(qwenConfig, null, 2), @@ -730,40 +809,11 @@ export async function buildQwenExtensionFromPlugin( */ export async function convertClaudePluginStandalone( extensionDir: string, + preserveHookVariables = false, + signal?: AbortSignal, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { - const pluginJsonPath = path.join( - extensionDir, - '.claude-plugin', - 'plugin.json', - ); - if (!fs.existsSync(pluginJsonPath)) { - throw new Error(`Plugin configuration not found at ${pluginJsonPath}`); - } - // The manifest may be a symlink in an untrusted clone; refuse to follow it - // outside the package (would read an arbitrary JSON-shaped host file). - if (!realPathWithin(pluginJsonPath, extensionDir)) { - throw new Error( - `Plugin configuration at ${pluginJsonPath} resolves through a symlink outside the plugin`, - ); - } - - const parsedConfig: unknown = JSON.parse( - fs.readFileSync(pluginJsonPath, 'utf-8'), - ); - // A plugin.json whose body is `null`, an array, or a scalar would otherwise - // throw an opaque `Cannot read properties of null` on the deref below. Fail - // with a clear message instead (the marketplace path tolerates this via - // `mergeClaudeConfigs`, so guard the standalone path to match). - if ( - typeof parsedConfig !== 'object' || - parsedConfig === null || - Array.isArray(parsedConfig) - ) { - throw new Error( - `Invalid plugin configuration at ${pluginJsonPath}: expected a JSON object`, - ); - } - const mergedConfig = parsedConfig as ClaudePluginConfig; + signal?.throwIfAborted(); + const mergedConfig = loadClaudePluginManifest(extensionDir); if (!mergedConfig.mcpServers) { const mcpJsonPath = path.join(extensionDir, '.mcp.json'); @@ -802,7 +852,12 @@ export async function convertClaudePluginStandalone( } } - return buildQwenExtensionFromPlugin(extensionDir, mergedConfig); + return buildQwenExtensionFromPlugin( + extensionDir, + mergedConfig, + preserveHookVariables, + signal, + ); } /** @@ -818,18 +873,21 @@ async function collectResources( resourcePaths: string | string[], pluginRoot: string, destDir: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); const paths = Array.isArray(resourcePaths) ? resourcePaths : [resourcePaths]; // Create destination directory if (!fs.existsSync(destDir)) { - fs.mkdirSync(destDir, { recursive: true }); + await fs.promises.mkdir(destDir, { recursive: true }); } // Get the destination folder name (e.g., 'commands', 'skills', 'agents') const destFolderName = path.basename(destDir); for (const resourcePath of paths) { + signal?.throwIfAborted(); // Resource paths come from an untrusted manifest; confine them to the // plugin so a value like "/etc/ssh" or "../../secrets" can't be copied in. const resolvedPath = resolvePluginRelativeFile(pluginRoot, resourcePath); @@ -868,9 +926,11 @@ async function collectResources( cwd: resolvedPath, nodir: true, dot: false, + signal, }); for (const file of files) { + signal?.throwIfAborted(); const srcFile = path.join(resolvedPath, file); const destFile = path.join(finalDestDir, file); @@ -902,10 +962,10 @@ async function collectResources( // Ensure parent directory exists const destFileDir = path.dirname(destFile); if (!fs.existsSync(destFileDir)) { - fs.mkdirSync(destFileDir, { recursive: true }); + await fs.promises.mkdir(destFileDir, { recursive: true }); } - fs.copyFileSync(srcFile, destFile); + await fs.promises.copyFile(srcFile, destFile); } } else { // File entry (e.g. `agents: ["./agents/wiki-architect.md"]`). @@ -914,9 +974,10 @@ async function collectResources( // "already in the destination folder". const fileName = path.basename(resolvedPath); const destFile = path.join(destDir, fileName); - fs.copyFileSync(resolvedPath, destFile); + await fs.promises.copyFile(resolvedPath, destFile); } } + signal?.throwIfAborted(); } /** @@ -1037,6 +1098,12 @@ async function resolvePluginSource( signal?.throwIfAborted(); const source = pluginConfig.source; + // A marketplace entry without `source` describes a plugin at the + // marketplace root. + if (source === undefined || source === null) { + return { pluginSource: marketplaceDir, externalContent: false }; + } + // Handle string source (relative path or URL) if (typeof source === 'string') { // Check if it's a URL (scheme is case-insensitive, e.g. HTTPS://) diff --git a/packages/core/src/extension/extension-converter.test.ts b/packages/core/src/extension/extension-converter.test.ts index 6a89c92e6f6..1063d1b43c9 100644 --- a/packages/core/src/extension/extension-converter.test.ts +++ b/packages/core/src/extension/extension-converter.test.ts @@ -1,19 +1,995 @@ /** * @license - * Copyright 2026 Qwen Team + * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { convertCompatibleExtension } from './extension-converter.js'; +import { convertCompatibleExtension as convertGeminiOrClaudeExtension } from './extension-converter.js'; +import { ExtensionManager, type ExtensionConfig } from './extensionManager.js'; +import { ExtensionStore } from './extension-store.js'; +import { ExtensionStorage } from './storage.js'; import { AGENT_PLUGIN_SCHEMA, AGENT_PLUGIN_SCHEMA_PREFIX, } from './agent-plugins-v1/index.js'; +function snapshotDirectory(root: string): Array<[string, string]> { + const snapshot: Array<[string, string]> = []; + const visit = (directory: string) => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const absolutePath = path.join(directory, entry.name); + const relativePath = path.relative(root, absolutePath); + if (entry.isDirectory()) { + snapshot.push([`${relativePath}/`, 'directory']); + visit(absolutePath); + } else if (entry.isSymbolicLink()) { + snapshot.push([ + relativePath, + `symlink:${fs.readlinkSync(absolutePath)}`, + ]); + } else { + snapshot.push([ + relativePath, + fs.readFileSync(absolutePath).toString('base64'), + ]); + } + } + }; + visit(root); + return snapshot.sort(([left], [right]) => left.localeCompare(right)); +} + +describe('convertGeminiOrClaudeExtension', () => { + let extensionDir: string; + let convertedDir: string | undefined; + let installRoot: string; + let installedDir: string | undefined; + + beforeEach(() => { + extensionDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dual-extension-')); + installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dual-installed-')); + }); + + afterEach(() => { + if (convertedDir && fs.existsSync(convertedDir)) { + fs.rmSync(convertedDir, { recursive: true, force: true }); + } + fs.rmSync(installRoot, { recursive: true, force: true }); + fs.rmSync(extensionDir, { recursive: true, force: true }); + }); + + it.each([ + { + installKind: 'standalone', + pluginName: undefined, + marketplacePluginName: 'ponytail', + marketplaceSource: './', + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: undefined, + includeMarketplace: true, + }, + { + installKind: 'named root', + pluginName: 'ponytail', + marketplacePluginName: 'ponytail', + marketplaceSource: './', + marketplaceHooks: './hooks/marketplace-hooks.json', + expectedHookPath: 'scripts/marketplace-session-start.sh', + pluginSourceKind: undefined, + includeMarketplace: true, + }, + { + installKind: 'aliased root', + pluginName: 'ponytail-alias', + marketplacePluginName: 'ponytail-alias', + marketplaceSource: './', + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: undefined, + includeMarketplace: true, + }, + { + installKind: 'direct root alias', + pluginName: 'ponytail-alias', + marketplacePluginName: 'different-plugin', + marketplaceSource: './plugins/different-plugin', + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: 'extension-root' as const, + includeMarketplace: true, + }, + { + installKind: 'matching direct root alias with a subplugin entry', + pluginName: 'ponytail-alias', + marketplacePluginName: 'ponytail-alias', + marketplaceSource: './plugins/ponytail-alias', + marketplaceHooks: './hooks/marketplace-hooks.json', + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: 'extension-root' as const, + includeMarketplace: true, + }, + { + installKind: 'legacy direct root alias', + pluginName: 'ponytail-alias', + marketplacePluginName: 'different-plugin', + marketplaceSource: './plugins/different-plugin', + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: undefined, + includeMarketplace: true, + }, + { + installKind: 'standalone without marketplace', + pluginName: undefined, + marketplacePluginName: 'ponytail', + marketplaceSource: './', + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: undefined, + includeMarketplace: false, + }, + { + installKind: 'named root without marketplace', + pluginName: 'ponytail', + marketplacePluginName: 'ponytail', + marketplaceSource: './', + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: 'marketplace-entry' as const, + includeMarketplace: false, + }, + { + installKind: 'sourceless marketplace root', + pluginName: 'ponytail', + marketplacePluginName: 'ponytail', + marketplaceSource: undefined, + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: 'marketplace-entry' as const, + includeMarketplace: true, + }, + { + installKind: 'null-source marketplace root', + pluginName: 'ponytail', + marketplacePluginName: 'ponytail', + marketplaceSource: null, + marketplaceHooks: undefined, + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: 'marketplace-entry' as const, + includeMarketplace: true, + }, + { + installKind: 'explicit matching extension root', + pluginName: 'ponytail', + marketplacePluginName: 'ponytail', + marketplaceSource: './', + marketplaceHooks: './hooks/marketplace-hooks.json', + expectedHookPath: 'scripts/session-start.sh', + pluginSourceKind: 'extension-root' as const, + includeMarketplace: true, + }, + { + installKind: 'explicit matching marketplace root', + pluginName: 'ponytail', + marketplacePluginName: 'ponytail', + marketplaceSource: './', + marketplaceHooks: './hooks/marketplace-hooks.json', + expectedHookPath: 'scripts/marketplace-session-start.sh', + pluginSourceKind: 'marketplace-entry' as const, + includeMarketplace: true, + }, + ])( + 'keeps Gemini resources and imports only selected root Claude hooks for a dual-manifest $installKind extension', + async ({ + pluginName, + marketplacePluginName, + marketplaceSource, + marketplaceHooks, + expectedHookPath, + pluginSourceKind, + includeMarketplace, + }) => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'ponytail', + version: '4.8.4', + contextFileName: 'AGENTS.md', + settings: [ + { + name: 'Ponytail mode', + envVar: 'PONYTAIL_MODE', + description: 'Select the ponytail mode', + }, + ], + }), + 'utf-8', + ); + fs.writeFileSync(path.join(extensionDir, 'AGENTS.md'), '# Ponytail'); + + const claudePluginDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(claudePluginDir, { recursive: true }); + if (includeMarketplace) { + fs.writeFileSync( + path.join(claudePluginDir, 'marketplace.json'), + JSON.stringify({ + name: 'ponytail', + owner: { name: 'Ponytail' }, + plugins: [ + { + name: marketplacePluginName, + source: marketplaceSource, + hooks: marketplaceHooks, + }, + ], + }), + 'utf-8', + ); + } + fs.writeFileSync( + path.join(claudePluginDir, 'plugin.json'), + JSON.stringify({ + name: 'ponytail', + version: '4.8.4', + description: 'A dual-manifest extension', + hooks: './hooks/claude-codex-hooks.json', + }), + 'utf-8', + ); + + const hooksDir = path.join(extensionDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'claude-codex-hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh', + }, + ], + }, + ], + }, + }), + 'utf-8', + ); + fs.writeFileSync( + path.join(hooksDir, 'marketplace-hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: + '${CLAUDE_PLUGIN_ROOT}/scripts/marketplace-session-start.sh', + }, + ], + }, + ], + }, + }), + 'utf-8', + ); + + const commandsDir = path.join(extensionDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync( + path.join(commandsDir, 'ponytail.toml'), + 'description = "Use ponytail mode"\nprompt = "Keep it simple"\n', + 'utf-8', + ); + + const sourceSnapshotBefore = snapshotDirectory(extensionDir); + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + pluginName, + undefined, + undefined, + pluginSourceKind, + ); + expect(snapshotDirectory(extensionDir)).toEqual(sourceSnapshotBefore); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(converted.originSource).toBe('Gemini'); + expect(converted.requiresClaudeFileAdaptation).toBe(true); + expect(config.name).toBe('ponytail'); + expect(config.contextFileName).toBe('AGENTS.md'); + expect(config.settings).toEqual([ + { + name: 'Ponytail mode', + envVar: 'PONYTAIL_MODE', + description: 'Select the ponytail mode', + }, + ]); + expect( + ( + config.hooks?.['SessionStart']?.[0].hooks?.[0] as { + command?: string; + } + )?.command, + ).toBe( + expectedHookPath + ? '${CLAUDE_PLUGIN_ROOT}/' + expectedHookPath + : undefined, + ); + expect(config.hooks?.['SessionStart']).toHaveLength( + expectedHookPath ? 1 : 0, + ); + expect( + fs.existsSync( + path.join(converted.extensionDir, 'commands', 'ponytail.md'), + ), + ).toBe(true); + expect( + fs.existsSync( + path.join(converted.extensionDir, 'commands', 'ponytail.toml'), + ), + ).toBe(false); + + installedDir = path.join(installRoot, 'ponytail'); + fs.renameSync(converted.extensionDir, installedDir); + convertedDir = undefined; + fs.rmSync(extensionDir, { recursive: true, force: true }); + + const manager = new ExtensionManager({ + workspaceDir: os.tmpdir(), + isWorkspaceTrusted: true, + extensionStore: new ExtensionStore({ + extensionsDir: installRoot, + storeDir: path.join(installRoot, '.store'), + }), + }); + const installedConfig = manager.loadExtensionConfig({ + extensionDir: installedDir, + }); + + const installedCommand = ( + installedConfig.hooks?.['SessionStart']?.[0].hooks?.[0] as { + command?: string; + } + )?.command; + expect(installedConfig.hooks?.['SessionStart']).toHaveLength( + expectedHookPath ? 1 : 0, + ); + expect(installedCommand && path.normalize(installedCommand)).toBe( + expectedHookPath + ? path.join(installedDir, expectedHookPath) + : undefined, + ); + }, + ); + + it('ignores an escaping marketplace manifest when selecting dual-manifest root hooks', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'safe-root', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ + name: 'safe-root', + version: '1.0.0', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/root-hook.sh', + }, + ], + }, + ], + }, + }), + ); + const outsideDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'dual-marketplace-'), + ); + fs.writeFileSync( + path.join(outsideDir, 'marketplace.json'), + JSON.stringify({ + name: 'untrusted', + owner: { name: 'Untrusted' }, + plugins: [{ name: 'safe-root', source: './' }], + }), + ); + fs.symlinkSync( + path.join(outsideDir, 'marketplace.json'), + path.join(manifestDir, 'marketplace.json'), + ); + + try { + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + 'safe-root', + ); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(converted.originSource).toBe('Gemini'); + expect(converted.requiresClaudeFileAdaptation).toBe(true); + expect( + ( + config.hooks?.['SessionStart']?.[0].hooks?.[0] as { + command?: string; + } + )?.command, + ).toBe('${CLAUDE_PLUGIN_ROOT}/scripts/root-hook.sh'); + } finally { + fs.rmSync(outsideDir, { recursive: true, force: true }); + } + }); + + it.each([ + { + format: 'Qwen', + manifest: 'qwen-extension.json', + expectedOrigin: 'QwenCode' as const, + }, + { + format: 'Gemini', + manifest: 'gemini-extension.json', + expectedOrigin: 'Gemini' as const, + }, + ])( + 'keeps a classic $format root extension when a named install has no marketplace', + async ({ manifest, expectedOrigin }) => { + fs.writeFileSync( + path.join(extensionDir, manifest), + JSON.stringify({ name: 'classic-root', version: '1.0.0' }), + ); + + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + 'classic-root', + undefined, + undefined, + 'marketplace-entry', + ); + if (converted.extensionDir !== extensionDir) { + convertedDir = converted.extensionDir; + } + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(converted.originSource).toBe(expectedOrigin); + expect(config.name).toBe('classic-root'); + expect(config.version).toBe('1.0.0'); + }, + ); + + it('treats pluginName as an alias for an explicit Claude extension-root install', async () => { + const claudeDir = path.join(extensionDir, '.claude-plugin'); + const hooksDir = path.join(extensionDir, 'hooks'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(claudeDir, 'marketplace.json'), + JSON.stringify({ + name: 'unrelated-marketplace', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'different-plugin', + source: './plugins/different-plugin', + }, + ], + }), + 'utf-8', + ); + fs.writeFileSync( + path.join(claudeDir, 'plugin.json'), + JSON.stringify({ + name: 'root-plugin', + version: '1.0.0', + hooks: './hooks/hooks.json', + }), + 'utf-8', + ); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/start.sh', + }, + ], + }, + ], + }, + }), + 'utf-8', + ); + + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + 'root-alias', + undefined, + undefined, + 'extension-root', + ); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(converted.originSource).toBe('Claude'); + expect(config.name).toBe('root-plugin'); + expect( + ( + config.hooks?.['SessionStart']?.[0].hooks?.[0] as { + command?: string; + } + )?.command, + ).toBe('${CLAUDE_PLUGIN_ROOT}/scripts/start.sh'); + }); + + it('installs a selected same-named subdirectory instead of the marketplace root Gemini extension', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'marketplace-root', + version: '1.0.0', + contextFileName: 'ROOT.md', + }), + 'utf-8', + ); + fs.writeFileSync(path.join(extensionDir, 'ROOT.md'), '# Root'); + + const rootClaudeDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(rootClaudeDir, { recursive: true }); + fs.writeFileSync( + path.join(rootClaudeDir, 'marketplace.json'), + JSON.stringify({ + name: 'marketplace-root', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'ponytail', + source: './plugins/ponytail', + }, + ], + }), + 'utf-8', + ); + + const pluginDir = path.join(extensionDir, 'plugins', 'ponytail'); + const pluginManifestDir = path.join(pluginDir, '.claude-plugin'); + const hooksDir = path.join(pluginDir, 'hooks'); + const scriptsDir = path.join(pluginDir, 'scripts'); + fs.mkdirSync(pluginManifestDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync( + path.join(pluginManifestDir, 'plugin.json'), + JSON.stringify({ + name: 'ponytail-child', + version: '2.0.0', + description: 'Selected child plugin', + hooks: './hooks/hooks.json', + }), + 'utf-8', + ); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/start.sh', + }, + ], + }, + ], + }, + }), + 'utf-8', + ); + fs.writeFileSync(path.join(scriptsDir, 'start.sh'), '#!/bin/sh\n'); + + const sourceSnapshotBefore = snapshotDirectory(extensionDir); + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + 'ponytail', + undefined, + undefined, + 'marketplace-entry', + ); + convertedDir = converted.extensionDir; + expect(snapshotDirectory(extensionDir)).toEqual(sourceSnapshotBefore); + + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + expect(converted.originSource).toBe('Claude'); + expect(config.name).toBe('ponytail'); + expect(config.description).toBe('Selected child plugin'); + expect(config.contextFileName).toBeUndefined(); + expect( + ( + config.hooks?.['SessionStart']?.[0].hooks?.[0] as { + command?: string; + } + )?.command, + ).toBe('${CLAUDE_PLUGIN_ROOT}/scripts/start.sh'); + expect( + fs.existsSync(path.join(converted.extensionDir, 'scripts', 'start.sh')), + ).toBe(true); + + installedDir = path.join(installRoot, 'ponytail-child'); + fs.renameSync(converted.extensionDir, installedDir); + convertedDir = undefined; + fs.rmSync(extensionDir, { recursive: true, force: true }); + + const manager = new ExtensionManager({ + workspaceDir: os.tmpdir(), + isWorkspaceTrusted: true, + extensionStore: new ExtensionStore({ + extensionsDir: installRoot, + storeDir: path.join(installRoot, '.store'), + }), + }); + const installedConfig = manager.loadExtensionConfig({ + extensionDir: installedDir, + }); + const installedCommand = ( + installedConfig.hooks?.['SessionStart']?.[0].hooks?.[0] as { + command?: string; + } + )?.command; + expect(installedCommand && path.normalize(installedCommand)).toBe( + path.join(installedDir, 'scripts', 'start.sh'), + ); + }); + + it('converts a named subplugin for legacy metadata without a source kind', async () => { + const rootClaudeDir = path.join(extensionDir, '.claude-plugin'); + const pluginDir = path.join(extensionDir, 'plugins', 'legacy-child'); + fs.mkdirSync(rootClaudeDir, { recursive: true }); + fs.mkdirSync(path.join(pluginDir, '.claude-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(rootClaudeDir, 'marketplace.json'), + JSON.stringify({ + name: 'legacy-marketplace', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'legacy-child', + source: './plugins/legacy-child', + }, + ], + }), + ); + fs.writeFileSync( + path.join(pluginDir, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'legacy-child', version: '2.0.0' }), + ); + + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + 'legacy-child', + ); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(converted.originSource).toBe('Claude'); + expect(config).toMatchObject({ name: 'legacy-child', version: '2.0.0' }); + }); + + it('merges conventional Gemini hooks with Claude hooks by event', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'dual-hooks', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + const hooksDir = path.join(extensionDir, 'hooks'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ + name: 'dual-hooks', + version: '1.0.0', + hooks: './hooks/claude-hooks.json', + }), + ); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: 'command', command: 'gemini-pre.sh' }] }, + ], + SessionStart: [ + { hooks: [{ type: 'command', command: 'gemini-start.sh' }] }, + ], + }, + }), + ); + fs.writeFileSync( + path.join(hooksDir, 'claude-hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'claude-start.sh' }] }, + ], + }, + }), + ); + + const converted = await convertGeminiOrClaudeExtension(extensionDir); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(config.hooks?.['PreToolUse']).toHaveLength(1); + expect(converted.requiresClaudeFileAdaptation).toBe(true); + expect( + config.hooks?.['SessionStart']?.map( + (definition) => + (definition.hooks?.[0] as { command?: string })?.command, + ), + ).toEqual(['gemini-start.sh', 'claude-start.sh']); + }); + + it.each(['constructor', '__proto__'])( + 'merges prototype-named %s hooks from conventional and Claude sources', + async (eventName) => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'prototype-hooks', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + const hooksDir = path.join(extensionDir, 'hooks'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ + name: 'prototype-hooks', + version: '1.0.0', + hooks: './hooks/claude-hooks.json', + }), + ); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + [eventName]: [ + { hooks: [{ type: 'command', command: 'conventional.sh' }] }, + ], + }, + }), + ); + fs.writeFileSync( + path.join(hooksDir, 'claude-hooks.json'), + JSON.stringify({ + hooks: { + [eventName]: [ + { hooks: [{ type: 'command', command: 'claude.sh' }] }, + ], + }, + }), + ); + + const converted = await convertGeminiOrClaudeExtension(extensionDir); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect( + Object.prototype.hasOwnProperty.call(config.hooks, eventName), + ).toBe(true); + expect( + (config.hooks as Record | undefined)?.[eventName], + ).toHaveLength(2); + }, + ); + + it('uses the selected root marketplace entry version in a dual manifest', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'versioned-root', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ name: 'versioned-root', version: '1.0.0' }), + ); + fs.writeFileSync( + path.join(manifestDir, 'marketplace.json'), + JSON.stringify({ + name: 'catalog', + owner: { name: 'Owner' }, + plugins: [{ name: 'versioned-root', version: '2.1.0', source: './' }], + }), + ); + + const converted = await convertGeminiOrClaudeExtension( + extensionDir, + 'versioned-root', + undefined, + undefined, + 'marketplace-entry', + ); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(config.version).toBe('2.1.0'); + }); + + it('keeps a valid Gemini conversion when Claude metadata is malformed', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'valid-gemini', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync(path.join(manifestDir, 'plugin.json'), '{'); + const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage); + const tempDirs: string[] = []; + const createTmpDirSpy = vi + .spyOn(ExtensionStorage, 'createTmpDir') + .mockImplementation(async () => { + const tempDir = await createTmpDir(); + tempDirs.push(tempDir); + return tempDir; + }); + + try { + const converted = await convertGeminiOrClaudeExtension(extensionDir); + convertedDir = converted.extensionDir; + const config = JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ) as ExtensionConfig; + + expect(converted.originSource).toBe('Gemini'); + expect(converted.requiresClaudeFileAdaptation).toBe(false); + expect(config.name).toBe('valid-gemini'); + expect(tempDirs.filter((tempDir) => fs.existsSync(tempDir))).toEqual([ + converted.extensionDir, + ]); + } finally { + createTmpDirSpy.mockRestore(); + } + }); + + it('reads Claude hooks without creating a second converted copy', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'cleanup-success', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ name: 'cleanup-success', version: '1.0.0' }), + ); + const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage); + const tempDirs: string[] = []; + const createTmpDirSpy = vi + .spyOn(ExtensionStorage, 'createTmpDir') + .mockImplementation(async () => { + const tempDir = await createTmpDir(); + tempDirs.push(tempDir); + return tempDir; + }); + + try { + const converted = await convertGeminiOrClaudeExtension(extensionDir); + convertedDir = converted.extensionDir; + expect(tempDirs).toHaveLength(1); + expect(converted.extensionDir).toBe(tempDirs[0]); + expect(fs.existsSync(tempDirs[0])).toBe(true); + } finally { + createTmpDirSpy.mockRestore(); + } + }); + + it('removes the Gemini conversion when hook loading observes an abort', async () => { + fs.writeFileSync( + path.join(extensionDir, 'gemini-extension.json'), + JSON.stringify({ name: 'cleanup-abort', version: '1.0.0' }), + ); + const manifestDir = path.join(extensionDir, '.claude-plugin'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ name: 'cleanup-abort', version: '1.0.0' }), + ); + const controller = new AbortController(); + const reason = new Error('conversion expired'); + const createTmpDir = ExtensionStorage.createTmpDir.bind(ExtensionStorage); + const tempDirs: string[] = []; + const createTmpDirSpy = vi + .spyOn(ExtensionStorage, 'createTmpDir') + .mockImplementation(async () => { + const tempDir = await createTmpDir(); + tempDirs.push(tempDir); + controller.abort(reason); + return tempDir; + }); + + try { + await expect( + convertGeminiOrClaudeExtension( + extensionDir, + undefined, + undefined, + controller.signal, + ), + ).rejects.toBe(reason); + expect(tempDirs).toHaveLength(1); + expect(tempDirs.every((tempDir) => !fs.existsSync(tempDir))).toBe(true); + } finally { + createTmpDirSpy.mockRestore(); + } + }); +}); describe('Agent Plugins extension conversion', () => { let pluginRoot: string; @@ -32,7 +1008,9 @@ describe('Agent Plugins extension conversion', () => { }); fs.writeFileSync(path.join(pluginRoot, 'plugin.json'), manifest); - await expect(convertCompatibleExtension(pluginRoot)).resolves.toEqual({ + await expect( + convertGeminiOrClaudeExtension(pluginRoot), + ).resolves.toMatchObject({ extensionDir: pluginRoot, originSource: 'AgentPlugins', externalContent: false, @@ -45,6 +1023,56 @@ describe('Agent Plugins extension conversion', () => { ); }); + it('detects a direct-root Agent Plugin even when it has an install alias', async () => { + fs.writeFileSync( + path.join(pluginRoot, 'plugin.json'), + JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: 'portable-plugin', + }), + ); + + await expect( + convertGeminiOrClaudeExtension( + pluginRoot, + 'catalog-alias', + undefined, + undefined, + 'extension-root', + ), + ).resolves.toMatchObject({ + extensionDir: pluginRoot, + originSource: 'AgentPlugins', + }); + }); + + it('does not parse a stray malformed Gemini manifest before selecting an Agent Plugin', async () => { + fs.writeFileSync( + path.join(pluginRoot, 'plugin.json'), + JSON.stringify({ + $schema: AGENT_PLUGIN_SCHEMA, + name: 'portable-plugin', + }), + ); + fs.writeFileSync( + path.join(pluginRoot, 'gemini-extension.json'), + '{ not valid json', + ); + + await expect( + convertGeminiOrClaudeExtension( + pluginRoot, + 'catalog-alias', + undefined, + undefined, + 'extension-root', + ), + ).resolves.toMatchObject({ + extensionDir: pluginRoot, + originSource: 'AgentPlugins', + }); + }); + it('gives an unsupported Agent Plugins schema priority over Qwen format', async () => { fs.writeFileSync( path.join(pluginRoot, 'plugin.json'), @@ -58,7 +1086,7 @@ describe('Agent Plugins extension conversion', () => { JSON.stringify({ name: 'qwen-fallback', version: '1.0.0' }), ); - await expect(convertCompatibleExtension(pluginRoot)).rejects.toThrow( + await expect(convertGeminiOrClaudeExtension(pluginRoot)).rejects.toThrow( 'Unsupported Agent Plugins schema', ); }); @@ -73,7 +1101,9 @@ describe('Agent Plugins extension conversion', () => { JSON.stringify({ name: 'qwen-extension', version: '1.0.0' }), ); - await expect(convertCompatibleExtension(pluginRoot)).resolves.toEqual({ + await expect( + convertGeminiOrClaudeExtension(pluginRoot), + ).resolves.toMatchObject({ extensionDir: pluginRoot, originSource: 'QwenCode', externalContent: false, @@ -119,9 +1149,12 @@ describe('Agent Plugins extension conversion', () => { }), ); - const selected = await convertCompatibleExtension( + const selected = await convertGeminiOrClaudeExtension( pluginRoot, 'requested-plugin', + undefined, + undefined, + 'marketplace-entry', ); expect(selected.originSource).toBe('Claude'); const selectedConfig = JSON.parse( @@ -150,9 +1183,12 @@ describe('Agent Plugins extension conversion', () => { name: 'future-carried-agent-plugin', }), ); - const selectedWithFutureRoot = await convertCompatibleExtension( + const selectedWithFutureRoot = await convertGeminiOrClaudeExtension( pluginRoot, 'requested-plugin', + undefined, + undefined, + 'marketplace-entry', ); expect(selectedWithFutureRoot.originSource).toBe('Claude'); expect( diff --git a/packages/core/src/extension/extension-converter.ts b/packages/core/src/extension/extension-converter.ts index c35e2477640..e997432042d 100644 --- a/packages/core/src/extension/extension-converter.ts +++ b/packages/core/src/extension/extension-converter.ts @@ -10,25 +10,33 @@ import { EXTENSIONS_CONFIG_FILENAME } from './variables.js'; import { convertGeminiExtensionPackage, isGeminiExtensionConfig, + realPathWithin, } from './gemini-converter.js'; import { convertClaudePluginPackage, convertClaudePluginStandalone, + loadClaudePluginHooks, + type ClaudeMarketplacePluginConfig, } from './claude-converter.js'; import { convertQoderPlugin, QODER_PLUGIN_MANIFEST, } from './qoder-converter.js'; +import type { ExtensionConfig } from './extensionManager.js'; import type { ExtensionNetworkPolicy, ExtensionOriginSource, + ExtensionPluginSourceKind, } from '../config/config.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; import { AGENT_PLUGIN_MANIFEST, AGENT_PLUGIN_SCHEMA, getAgentPluginSchemaStatus, } from './agent-plugins-v1/manifest.js'; +const debugLogger = createDebugLogger('EXTENSION_CONVERTER'); + export const SUPPORTED_EXTENSION_MANIFESTS = [ EXTENSIONS_CONFIG_FILENAME, 'gemini-extension.json', @@ -37,49 +45,306 @@ export const SUPPORTED_EXTENSION_MANIFESTS = [ QODER_PLUGIN_MANIFEST, ] as const; +async function removeConvertedDirectory(directory: string): Promise { + try { + await fs.promises.rm(directory, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors so they do not mask the conversion result. + } +} + +type MarketplacePluginSelection = + | { + location: 'root'; + version?: string; + plugin: ClaudeMarketplacePluginConfig; + } + | { location: 'other' | 'missing-marketplace' }; + +function selectedMarketplacePlugin( + extensionDir: string, + pluginName: string, +): MarketplacePluginSelection { + const marketplacePath = path.join( + extensionDir, + SUPPORTED_EXTENSION_MANIFESTS[2], + ); + try { + fs.lstatSync(marketplacePath); + } catch { + return { location: 'missing-marketplace' }; + } + if ( + !fs.existsSync(marketplacePath) || + !realPathWithin(marketplacePath, extensionDir) + ) { + return { location: 'other' }; + } + + try { + const marketplace: unknown = JSON.parse( + fs.readFileSync(marketplacePath, 'utf-8'), + ); + if ( + typeof marketplace !== 'object' || + marketplace === null || + !Array.isArray((marketplace as { plugins?: unknown }).plugins) + ) { + return { location: 'other' }; + } + + const selectedPlugin = ( + marketplace as { plugins: Array> } + ).plugins.find((plugin) => plugin['name'] === pluginName); + if (!selectedPlugin) { + return { location: 'other' }; + } + + const source = selectedPlugin['source']; + const version = + typeof selectedPlugin['version'] === 'string' + ? selectedPlugin['version'] + : undefined; + // Claude marketplaces allow an entry without `source`; that entry refers + // to the marketplace root itself. + if (source === undefined || source === null) { + return { + location: 'root', + version, + plugin: selectedPlugin as unknown as ClaudeMarketplacePluginConfig, + }; + } + if (typeof source !== 'string') return { location: 'other' }; + + return path.resolve(path.join(extensionDir, source)) === + path.resolve(extensionDir) + ? { + location: 'root', + version, + plugin: selectedPlugin as unknown as ClaudeMarketplacePluginConfig, + } + : { location: 'other' }; + } catch { + return { location: 'other' }; + } +} + +type ExtensionHooks = NonNullable; + +function loadConventionalHooks( + convertedDir: string, +): ExtensionHooks | undefined { + const hooksPath = path.join(convertedDir, 'hooks', 'hooks.json'); + if (!fs.existsSync(hooksPath) || !realPathWithin(hooksPath, convertedDir)) { + return undefined; + } + + try { + const parsed: unknown = JSON.parse(fs.readFileSync(hooksPath, 'utf-8')); + if (typeof parsed !== 'object' || parsed === null) return undefined; + const hooks = (parsed as { hooks?: unknown }).hooks ?? parsed; + return typeof hooks === 'object' && hooks !== null + ? (hooks as ExtensionHooks) + : undefined; + } catch (error) { + debugLogger.warn( + `Failed to parse hooks file ${hooksPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return undefined; + } +} + +function mergeHooks( + ...sources: Array +): ExtensionHooks | undefined { + const merged = Object.create(null) as ExtensionHooks; + for (const source of sources) { + if (!source) continue; + for (const [event, definitions] of Object.entries(source)) { + if (!Array.isArray(definitions)) continue; + const existing = merged[event as keyof ExtensionHooks] ?? []; + const serialized = new Set( + existing.map((entry) => JSON.stringify(entry)), + ); + const uniqueDefinitions = definitions.filter((entry) => { + const key = JSON.stringify(entry); + if (serialized.has(key)) return false; + serialized.add(key); + return true; + }); + merged[event as keyof ExtensionHooks] = [ + ...existing, + ...uniqueDefinitions, + ]; + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} + export async function convertCompatibleExtension( extensionDir: string, pluginName?: string, networkPolicy?: ExtensionNetworkPolicy, signal?: AbortSignal, + pluginSourceKind?: ExtensionPluginSourceKind, ): Promise<{ extensionDir: string; originSource: ExtensionOriginSource; externalContent: boolean; + requiresClaudeFileAdaptation: boolean; }> { signal?.throwIfAborted(); let newExtensionDir = extensionDir; let originSource: ExtensionOriginSource = 'QwenCode'; let externalContent = false; - const agentPluginStatus = pluginName - ? 'unrelated' - : getAgentPluginSchemaStatus(extensionDir); + let requiresClaudeFileAdaptation = false; + const isExplicitMarketplaceEntry = pluginSourceKind === 'marketplace-entry'; + const isExplicitExtensionRoot = pluginSourceKind === 'extension-root'; + // A direct-root alias must not suppress Agent Plugins detection. Legacy + // named installs and explicit marketplace selectors retain selector-first + // behavior. + const agentPluginStatus = + pluginName && !isExplicitExtensionRoot + ? 'unrelated' + : getAgentPluginSchemaStatus(extensionDir); const configFilePath = path.join( extensionDir, SUPPORTED_EXTENSION_MANIFESTS[0], ); + const hasQwenConfig = fs.existsSync(configFilePath); + const isGeminiExtension = + agentPluginStatus === 'unrelated' && + !hasQwenConfig && + isGeminiExtensionConfig(extensionDir); + const hasClaudePlugin = fs.existsSync( + path.join(extensionDir, SUPPORTED_EXTENSION_MANIFESTS[3]), + ); + + // `pluginName` has two meanings: a selector inside a marketplace repo, or an + // alias retained for a direct plugin-root install. New metadata disambiguates + // them. Legacy metadata keeps the old manifest-first behavior so an update + // cannot suddenly replace a previously installed root Gemini/Qwen extension + // with a marketplace subplugin. + const marketplaceSelection = pluginName + ? selectedMarketplacePlugin(extensionDir, pluginName) + : { location: 'missing-marketplace' as const }; + const selectedMarketplaceEntryUsesRoot = + marketplaceSelection.location === 'root'; + const rootMarketplacePluginName = + pluginName && !isExplicitExtensionRoot && selectedMarketplaceEntryUsesRoot + ? pluginName + : undefined; + if (agentPluginStatus === 'unsupported') { throw new Error( `Unsupported Agent Plugins schema. Supported schema: "${AGENT_PLUGIN_SCHEMA}".`, ); } else if (agentPluginStatus === 'supported') { originSource = 'AgentPlugins'; - } else if (fs.existsSync(configFilePath)) { + // A selected subdirectory/remote marketplace plugin must win over manifests + // at the marketplace repository root. Only explicit new metadata opts into + // this precedence; legacy installs retain their previous root selection. + } else if ( + isExplicitMarketplaceEntry && + pluginName && + marketplaceSelection.location !== 'missing-marketplace' && + !selectedMarketplaceEntryUsesRoot + ) { + const converted = await convertClaudePluginPackage( + extensionDir, + pluginName, + networkPolicy, + signal, + true, + ); + newExtensionDir = converted.convertedDir; + if (getAgentPluginSchemaStatus(newExtensionDir) !== 'unrelated') { + fs.rmSync(path.join(newExtensionDir, AGENT_PLUGIN_MANIFEST), { + force: true, + }); + } + originSource = 'Claude'; + externalContent = converted.externalContent; + } else if (hasQwenConfig) { newExtensionDir = extensionDir; - } else if (isGeminiExtensionConfig(extensionDir)) { - newExtensionDir = (await convertGeminiExtensionPackage(extensionDir)) - .convertedDir; + } else if (isGeminiExtension && hasClaudePlugin) { + const geminiConversion = await convertGeminiExtensionPackage( + extensionDir, + signal, + ); + try { + signal?.throwIfAborted(); + let claudeHooks: ExtensionHooks | undefined; + let claudeMetadataLoaded = false; + try { + claudeHooks = loadClaudePluginHooks( + extensionDir, + rootMarketplacePluginName && marketplaceSelection.location === 'root' + ? marketplaceSelection.plugin + : undefined, + signal, + ); + claudeMetadataLoaded = true; + } catch (error) { + signal?.throwIfAborted(); + debugLogger.warn( + `Failed to import Claude plugin metadata; keeping the Gemini extension: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + if (!claudeMetadataLoaded) { + newExtensionDir = geminiConversion.convertedDir; + originSource = 'Gemini'; + return { + extensionDir: newExtensionDir, + originSource, + externalContent, + requiresClaudeFileAdaptation, + }; + } + + const conventionalHooks = loadConventionalHooks( + geminiConversion.convertedDir, + ); + const geminiHooks = geminiConversion.config.hooks ?? conventionalHooks; + const mergedConfig = { + ...geminiConversion.config, + ...(rootMarketplacePluginName && + marketplaceSelection.location === 'root' && + marketplaceSelection.version + ? { version: marketplaceSelection.version } + : {}), + hooks: mergeHooks(geminiHooks, claudeHooks), + }; + signal?.throwIfAborted(); + fs.writeFileSync( + path.join(geminiConversion.convertedDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(mergedConfig, null, 2), + 'utf-8', + ); + newExtensionDir = geminiConversion.convertedDir; + originSource = 'Gemini'; + requiresClaudeFileAdaptation = Boolean(conventionalHooks || claudeHooks); + } catch (error) { + await removeConvertedDirectory(geminiConversion.convertedDir); + throw error; + } + } else if (isGeminiExtension) { + newExtensionDir = ( + await convertGeminiExtensionPackage(extensionDir, signal) + ).convertedDir; originSource = 'Gemini'; - } else if (pluginName) { - // 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. + } else if (pluginName && !isExplicitExtensionRoot) { const converted = await convertClaudePluginPackage( extensionDir, pluginName, networkPolicy, signal, + true, ); newExtensionDir = converted.convertedDir; if (getAgentPluginSchemaStatus(newExtensionDir) !== 'unrelated') { @@ -90,15 +355,20 @@ export async function convertCompatibleExtension( 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])) - ) { - newExtensionDir = (await convertClaudePluginStandalone(extensionDir)) + newExtensionDir = (await convertQoderPlugin(extensionDir, signal)) .convertedDir; + originSource = 'Qoder'; + } else if (hasClaudePlugin) { + newExtensionDir = ( + await convertClaudePluginStandalone(extensionDir, true, signal) + ).convertedDir; originSource = 'Claude'; } signal?.throwIfAborted(); - return { extensionDir: newExtensionDir, originSource, externalContent }; + return { + extensionDir: newExtensionDir, + originSource, + externalContent, + requiresClaudeFileAdaptation, + }; } diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index c7f6f180e3b..d9c3711672d 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -30,6 +30,7 @@ import { import type { MCPServerConfig, ExtensionInstallMetadata } from '../index.js'; import { ExtensionStore } from './extension-store.js'; import { ExtensionPreferencesStore } from './extensionPreferences.js'; +import { parseInstallSource } from './marketplace.js'; import { AGENT_PLUGIN_MCP_SCHEMA, AGENT_PLUGIN_SCHEMA, @@ -754,6 +755,163 @@ describe('extension tests', () => { expect(fs.existsSync(installed.path)).toBe(false); }); + it('does not re-prompt for a marketplace plugin when the source is an extension root', async () => { + const sourceDir = path.join(tempWorkspaceDir, 'direct-root-source'); + writeExtractedExtension(sourceDir, 'direct-root-extension'); + const requestChoicePlugin = vi.fn(async () => 'wrong-subplugin'); + const manager = createExtensionManager({ requestChoicePlugin }); + + const installed = await manager.installExtension( + { + type: 'local', + source: sourceDir, + originSource: 'Claude', + pluginSourceKind: 'extension-root', + marketplaceConfig: { + name: 'unrelated-marketplace', + owner: { name: 'Owner', email: 'owner@example.com' }, + plugins: [ + { + name: 'wrong-subplugin', + version: '1.0.0', + source: './plugins/wrong-subplugin', + }, + ], + }, + }, + async () => {}, + ); + + expect(requestChoicePlugin).not.toHaveBeenCalled(); + expect(installed.name).toBe('direct-root-extension'); + }); + + it('preserves the CLI marketplace-entry kind through a dual-manifest install', async () => { + const sourceDir = path.join(tempWorkspaceDir, 'cli-dual-source'); + const manifestDir = path.join(sourceDir, '.claude-plugin'); + const hooksDir = path.join(sourceDir, 'hooks'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(sourceDir, 'gemini-extension.json'), + JSON.stringify({ + name: 'cli-dual', + version: '1.0.0', + contextFileName: 'AGENTS.md', + settings: [ + { + name: 'Mode', + envVar: 'CLI_DUAL_MODE', + description: 'CLI dual mode', + }, + ], + }), + ); + fs.writeFileSync(path.join(sourceDir, 'AGENTS.md'), '# CLI dual'); + const marketplaceConfig = { + name: 'cli-dual-marketplace', + owner: { name: 'Owner', email: 'owner@example.com' }, + plugins: [{ name: 'cli-dual', version: '1.0.0', source: './' }], + }; + fs.writeFileSync( + path.join(manifestDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig), + ); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ + name: 'cli-dual', + version: '1.0.0', + hooks: './hooks/hooks.json', + }), + ); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { hooks: [{ type: 'command', command: 'session-start.sh' }] }, + ], + }, + }), + ); + + const installMetadata = await parseInstallSource(`${sourceDir}:cli-dual`); + expect(installMetadata.pluginSourceKind).toBe('marketplace-entry'); + const manager = createExtensionManager(); + const installed = await manager.installExtension( + installMetadata, + async () => {}, + async () => 'enabled', + ); + const persistedMetadata = JSON.parse( + fs.readFileSync( + path.join(installed.path, INSTALL_METADATA_FILENAME), + 'utf-8', + ), + ) as ExtensionInstallMetadata; + + expect(installed.config.contextFileName).toBe('AGENTS.md'); + expect(installed.config.settings).toHaveLength(1); + expect(installed.hooks?.['SessionStart']).toHaveLength(1); + expect(persistedMetadata.pluginSourceKind).toBe('marketplace-entry'); + expect(persistedMetadata.pluginName).toBe('cli-dual'); + }); + + it('persists marketplace-entry after an interactive plugin choice', async () => { + const sourceDir = path.join(tempWorkspaceDir, 'prompt-marketplace'); + const manifestDir = path.join(sourceDir, '.claude-plugin'); + const childManifestDir = path.join( + sourceDir, + 'plugins', + 'chosen-plugin', + '.claude-plugin', + ); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.mkdirSync(childManifestDir, { recursive: true }); + const marketplaceConfig = { + name: 'prompt-marketplace', + owner: { name: 'Owner', email: 'owner@example.com' }, + plugins: [ + { + name: 'chosen-plugin', + version: '2.0.0', + source: './plugins/chosen-plugin', + }, + ], + }; + fs.writeFileSync( + path.join(manifestDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig), + ); + fs.writeFileSync( + path.join(childManifestDir, 'plugin.json'), + JSON.stringify({ name: 'chosen-plugin', version: '2.0.0' }), + ); + const requestChoicePlugin = vi.fn(async () => 'chosen-plugin'); + const manager = createExtensionManager({ requestChoicePlugin }); + + const installed = await manager.installExtension( + { + type: 'local', + source: sourceDir, + originSource: 'Claude', + marketplaceConfig, + }, + async () => {}, + ); + const persistedMetadata = JSON.parse( + fs.readFileSync( + path.join(installed.path, INSTALL_METADATA_FILENAME), + 'utf-8', + ), + ) as ExtensionInstallMetadata; + + expect(requestChoicePlugin).toHaveBeenCalledOnce(); + expect(persistedMetadata.pluginName).toBe('chosen-plugin'); + expect(persistedMetadata.pluginSourceKind).toBe('marketplace-entry'); + }); + it('commits workspace initial activation with the installed artifact', async () => { const archivePath = path.join(tempWorkspaceDir, 'workspace-ext.zip'); fs.writeFileSync(archivePath, 'archive'); @@ -1041,6 +1199,100 @@ describe('extension tests', () => { } }); + it('adapts Claude hook files in a dual-manifest Gemini extension', async () => { + const archivePath = path.join(tempWorkspaceDir, 'dual-manifest.zip'); + fs.writeFileSync(archivePath, 'archive'); + mockExtractArchiveFile.mockImplementation( + async (_source: string, destination: string) => { + fs.mkdirSync(destination, { recursive: true }); + fs.writeFileSync( + path.join(destination, 'gemini-extension.json'), + JSON.stringify({ + name: 'dual-manifest', + version: '1.0.0', + settings: [ + { + name: 'Mode', + envVar: 'DUAL_MODE', + description: 'Dual mode', + }, + ], + }), + ); + const manifestDir = path.join(destination, '.claude-plugin'); + const hooksDir = path.join(destination, 'hooks'); + const scriptsDir = path.join(destination, 'scripts'); + fs.mkdirSync(manifestDir, { recursive: true }); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.mkdirSync(scriptsDir, { recursive: true }); + fs.writeFileSync( + path.join(manifestDir, 'plugin.json'), + JSON.stringify({ + name: 'dual-manifest', + version: '1.0.0', + hooks: './hooks/hooks.json', + }), + ); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: + '${CLAUDE_PLUGIN_ROOT}/scripts/session-start.sh', + }, + ], + }, + ], + }, + }), + ); + fs.writeFileSync( + path.join(scriptsDir, 'session-start.sh'), + 'jq \'.message.content | map(select(.type == "text"))\' ~/.claude/transcript\n', + ); + }, + ); + const manager = createExtensionManager(); + const prepared = await manager.prepareExtensionInstall({ + installMetadata: { type: 'local', source: archivePath }, + initialActivation: { scope: 'user' }, + requestConsent: async () => {}, + requestSetting: async () => 'enabled', + }); + + try { + const stagedConfig = JSON.parse( + fs.readFileSync( + path.join(prepared.stagingDirectory, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ) as ExtensionConfig; + const stagedMetadata = JSON.parse( + fs.readFileSync( + path.join(prepared.stagingDirectory, INSTALL_METADATA_FILENAME), + 'utf-8', + ), + ) as ExtensionInstallMetadata; + const script = fs.readFileSync( + path.join(prepared.stagingDirectory, 'scripts', 'session-start.sh'), + 'utf-8', + ); + + expect(stagedMetadata.originSource).toBe('Gemini'); + expect(stagedConfig.settings).toHaveLength(1); + expect(stagedConfig.hooks?.['SessionStart']).toHaveLength(1); + expect(script).toContain('.message.parts | map(select(has("text")))'); + expect(script).toContain('~/.qwen/transcript'); + } finally { + await manager.disposePreparedExtension(prepared); + } + }); + it('does not report a temp cleanup warning when an immediate retry succeeds', async () => { const archivePath = path.join(tempWorkspaceDir, 'cleanup-warning.zip'); fs.writeFileSync(archivePath, 'archive'); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 3c4bd933b95..ff797cc8353 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -54,6 +54,7 @@ import { } from './extensionPreferences.js'; import { SourceRegistryStore, + discoveredPluginInstallIdentity, discoverPlugins, parseExtensionSourceType, type ExtensionSource, @@ -1215,18 +1216,37 @@ export class ExtensionManager { async discoverPlugins(options?: { refresh?: boolean; }): Promise { - const installedNames = new Set( - this.getLoadedExtensions().map((ext) => ext.name), + const loadedExtensions = this.getLoadedExtensions(); + const installedIdentities = new Set( + loadedExtensions.flatMap((extension) => { + const metadata = extension.installMetadata; + if (!metadata) return []; + const installSource = metadata.pluginName + ? `${metadata.source}:${metadata.pluginName}` + : metadata.source; + return [ + discoveredPluginInstallIdentity( + installSource, + metadata.pluginSourceKind, + ), + ]; + }), ); if (this.discoverCache && !options?.refresh) { return this.discoverCache.map((plugin) => ({ ...plugin, - installed: installedNames.has(plugin.name), + installed: + (plugin.installIdentity !== undefined && + installedIdentities.has(plugin.installIdentity)) || + loadedExtensions.some((extension) => extension.name === plugin.name), })); } const result = await discoverPlugins( this.getSources(), - installedNames, + new Set([ + ...installedIdentities, + ...loadedExtensions.map((extension) => extension.name), + ]), this.networkPolicy, ); this.discoverCache = result; @@ -2017,12 +2037,14 @@ export class ExtensionManager { if ( installMetadata.originSource === 'Claude' && installMetadata.marketplaceConfig && + installMetadata.pluginSourceKind !== 'extension-root' && !installMetadata.pluginName ) { const pluginName = await this.requestChoicePlugin( installMetadata.marketplaceConfig, ); installMetadata.pluginName = pluginName; + installMetadata.pluginSourceKind = 'marketplace-entry'; } if ( @@ -2108,13 +2130,18 @@ export class ExtensionManager { signal?.throwIfAborted(); try { const sourceBeforeConversion = localSourcePath; - const { extensionDir, originSource, externalContent } = - await convertCompatibleExtension( - sourceBeforeConversion, - installMetadata.pluginName, - installMetadata.networkPolicy, - signal, - ); + const { + extensionDir, + originSource, + externalContent, + requiresClaudeFileAdaptation, + } = await convertCompatibleExtension( + sourceBeforeConversion, + installMetadata.pluginName, + installMetadata.networkPolicy, + signal, + installMetadata.pluginSourceKind, + ); signal?.throwIfAborted(); if (extensionDir !== sourceBeforeConversion) { @@ -2308,7 +2335,9 @@ export class ExtensionManager { : null; const usesPluginVariables = - originSource === 'Claude' || originSource === 'Qoder'; + originSource === 'Claude' || + originSource === 'Qoder' || + requiresClaudeFileAdaptation; if ( usesPluginVariables && (fs.existsSync(hooksDir) || diff --git a/packages/core/src/extension/gemini-converter.ts b/packages/core/src/extension/gemini-converter.ts index bc8b44e985f..d5ef2f61044 100644 --- a/packages/core/src/extension/gemini-converter.ts +++ b/packages/core/src/extension/gemini-converter.ts @@ -77,7 +77,9 @@ export function convertGeminiToQwenConfig( */ export async function convertGeminiExtensionPackage( extensionDir: string, + signal?: AbortSignal, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { + signal?.throwIfAborted(); const geminiConfig = convertGeminiToQwenConfig(extensionDir); // Create temporary directory for converted extension @@ -85,15 +87,16 @@ export async function convertGeminiExtensionPackage( try { // Step 1: Copy all files and directories to temporary directory - await copyDirectory(extensionDir, tmpDir); + await copyDirectory(extensionDir, tmpDir, undefined, signal); // Step 2: Convert TOML commands to Markdown in commands folder const commandsDir = path.join(tmpDir, 'commands'); if (fs.existsSync(commandsDir)) { - await convertCommandsDirectory(commandsDir); + await convertCommandsDirectory(commandsDir, signal); } // Step 3: Create qwen-extension.json with converted config + signal?.throwIfAborted(); const qwenConfigPath = path.join(tmpDir, 'qwen-extension.json'); fs.writeFileSync( qwenConfigPath, @@ -151,10 +154,12 @@ export async function copyDirectory( source: string, destination: string, confineRoot?: string, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); // Create destination directory if it doesn't exist if (!fs.existsSync(destination)) { - fs.mkdirSync(destination, { recursive: true }); + await fs.promises.mkdir(destination, { recursive: true }); } // Symlinks in an (untrusted) source are dereferenced and their *target* @@ -171,14 +176,15 @@ export async function copyDirectory( } } - const entries = fs.readdirSync(source, { withFileTypes: true }); + const entries = await fs.promises.readdir(source, { withFileTypes: true }); for (const entry of entries) { + signal?.throwIfAborted(); const sourcePath = path.join(source, entry.name); const destPath = path.join(destination, entry.name); if (entry.isDirectory()) { - await copyDirectory(sourcePath, destPath, root); + await copyDirectory(sourcePath, destPath, root, signal); } else if (entry.isSymbolicLink()) { // Resolve symlink and copy the target content, but only when the target // stays inside the package root. @@ -192,40 +198,47 @@ export async function copyDirectory( } const targetStat = fs.statSync(realPath); if (targetStat.isDirectory()) { - await copyDirectory(realPath, destPath, root); + await copyDirectory(realPath, destPath, root, signal); } else if (targetStat.isFile()) { - fs.copyFileSync(realPath, destPath); + await fs.promises.copyFile(realPath, destPath); } // Skip sockets, FIFOs, etc. } catch { + signal?.throwIfAborted(); // Skip broken symlinks } } else if (entry.isFile()) { - fs.copyFileSync(sourcePath, destPath); + await fs.promises.copyFile(sourcePath, destPath); } // Skip sockets, FIFOs, block devices, and character devices } + signal?.throwIfAborted(); } /** * Converts all TOML command files in a directory to Markdown format. * @param commandsDir Path to the commands directory */ -async function convertCommandsDirectory(commandsDir: string): Promise { +async function convertCommandsDirectory( + commandsDir: string, + signal?: AbortSignal, +): Promise { // Find all .toml files in the commands directory const tomlFiles = await glob('**/*.toml', { cwd: commandsDir, nodir: true, dot: false, + signal, }); // Convert each TOML file to Markdown for (const relativeFile of tomlFiles) { + signal?.throwIfAborted(); const tomlPath = path.join(commandsDir, relativeFile); try { // Read TOML file - const tomlContent = fs.readFileSync(tomlPath, 'utf-8'); + const tomlContent = await fs.promises.readFile(tomlPath, 'utf-8'); // Convert to Markdown const markdownContent = convertTomlToMarkdown(tomlContent); @@ -234,11 +247,12 @@ async function convertCommandsDirectory(commandsDir: string): Promise { const markdownPath = tomlPath.replace(/\.toml$/, '.md'); // Write Markdown file - fs.writeFileSync(markdownPath, markdownContent, 'utf-8'); + await fs.promises.writeFile(markdownPath, markdownContent, 'utf-8'); // Delete original TOML file - fs.unlinkSync(tomlPath); + await fs.promises.unlink(tomlPath); } catch (error) { + signal?.throwIfAborted(); debugLogger.warn( `Warning: Failed to convert command file ${relativeFile}: ${error instanceof Error ? error.message : String(error)}`, ); diff --git a/packages/core/src/extension/github.test.ts b/packages/core/src/extension/github.test.ts index eef3edff711..121e2600323 100644 --- a/packages/core/src/extension/github.test.ts +++ b/packages/core/src/extension/github.test.ts @@ -32,6 +32,7 @@ import * as archiver from 'archiver'; import { ExtensionUpdateState, type Extension, + type ExtensionConfig, type ExtensionManager, } from './extensionManager.js'; import { getErrorMessage } from '../utils/errors.js'; @@ -1192,6 +1193,97 @@ describe('git extension helpers', () => { expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE); }); + it('does not refetch external content selected by a local marketplace', async () => { + const extension = createExtension({ + installMetadata: { + type: 'local', + source: '/local/marketplace', + pluginName: 'remote-child', + pluginSourceKind: 'marketplace-entry', + externalContent: true, + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn(), + } as unknown as ExtensionManager; + + await expect( + checkForExtensionUpdate(extension, mockManager), + ).resolves.toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockManager.loadExtensionConfig).not.toHaveBeenCalled(); + }); + + it('checks a selected marketplace entry when its local source is a directory', async () => { + const sourceDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-marketplace-update-test-'), + ); + try { + await fs.writeFile( + path.join(sourceDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ name: 'marketplace-root', version: '1.0.0' }), + ); + const marketplaceDir = path.join(sourceDir, '.claude-plugin'); + const pluginDir = path.join(sourceDir, 'plugins', 'selected'); + await fs.mkdir(marketplaceDir, { recursive: true }); + await fs.mkdir(path.join(pluginDir, '.claude-plugin'), { + recursive: true, + }); + await fs.writeFile( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify({ + name: 'marketplace-root', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'selected', + source: './plugins/selected', + }, + ], + }), + ); + await fs.writeFile( + path.join(pluginDir, '.claude-plugin', 'plugin.json'), + JSON.stringify({ name: 'selected', version: '2.0.0' }), + ); + const loadedNames: string[] = []; + const mockManager = { + loadExtensionConfig: vi.fn( + ({ extensionDir }: { extensionDir: string }) => { + const config = JSON.parse( + fsSync.readFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + 'utf-8', + ), + ) as ExtensionConfig; + loadedNames.push(config.name); + return config; + }, + ), + } as unknown as ExtensionManager; + const installMetadata = { + type: 'local' as const, + source: sourceDir, + pluginName: 'selected', + pluginSourceKind: 'marketplace-entry' as const, + }; + + const unchanged = await checkForExtensionUpdate( + createExtension({ version: '2.0.0', installMetadata }), + mockManager, + ); + const outdated = await checkForExtensionUpdate( + createExtension({ version: '1.0.0', installMetadata }), + mockManager, + ); + + expect(unchanged).toBe(ExtensionUpdateState.UP_TO_DATE); + expect(outdated).toBe(ExtensionUpdateState.UPDATE_AVAILABLE); + expect(loadedNames).toEqual(['selected', 'selected']); + } finally { + await fs.rm(sourceDir, { recursive: true, force: true }); + } + }); + it('should convert a local Gemini archive before checking for updates', async () => { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'local-archive-update-test-'), @@ -1241,6 +1333,72 @@ describe('git extension helpers', () => { } }); + it('forwards extension-root kind when checking a local archive update', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'local-root-alias-update-test-'), + ); + try { + const archivePath = path.join(tempDir, 'root-alias.zip'); + const archive = await createZipBuffer(tempDir, [ + { + name: '.claude-plugin/plugin.json', + content: JSON.stringify({ + name: 'root-plugin', + version: '1.0.0', + }), + }, + { + name: '.claude-plugin/marketplace.json', + content: JSON.stringify({ + name: 'root-marketplace', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'root-alias', + version: '2.0.0', + source: './plugins/root-alias', + }, + ], + }), + }, + { + name: 'plugins/root-alias/.claude-plugin/plugin.json', + content: JSON.stringify({ + name: 'child-plugin', + version: '2.0.0', + }), + }, + ]); + await fs.writeFile(archivePath, archive); + const extension = createExtension({ + version: '1.0.0', + installMetadata: { + type: 'local', + source: archivePath, + pluginName: 'root-alias', + pluginSourceKind: 'extension-root', + }, + }); + 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.UP_TO_DATE); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('should return UPDATE_AVAILABLE for local archive extension with different version', async () => { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'local-archive-update-test-'), @@ -1416,6 +1574,27 @@ describe('git extension helpers', () => { } }); + it('does not refetch external content selected by an archive URL marketplace', async () => { + const extension = createExtension({ + installMetadata: { + type: 'archive-url', + source: 'https://example.com/catalog.zip', + pluginName: 'remote-child', + pluginSourceKind: 'marketplace-entry', + externalContent: true, + }, + }); + const mockManager = { + loadExtensionConfig: vi.fn(), + } as unknown as ExtensionManager; + + await expect( + checkForExtensionUpdate(extension, mockManager), + ).resolves.toBe(ExtensionUpdateState.NOT_UPDATABLE); + expect(mockHttpsGet).not.toHaveBeenCalled(); + expect(mockManager.loadExtensionConfig).not.toHaveBeenCalled(); + }); + it('should convert an archive URL Gemini archive before checking for updates', async () => { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'archive-url-update-test-'), @@ -1464,6 +1643,71 @@ describe('git extension helpers', () => { } }); + it('forwards marketplace-entry kind when checking an archive URL update', async () => { + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'archive-url-marketplace-update-test-'), + ); + try { + const archive = await createZipBuffer(tempDir, [ + { + name: EXTENSIONS_CONFIG_FILENAME, + content: JSON.stringify({ + name: 'marketplace-root', + version: '2.0.0', + }), + }, + { + name: '.claude-plugin/marketplace.json', + content: JSON.stringify({ + name: 'catalog', + owner: { name: 'Owner' }, + plugins: [ + { + name: 'selected', + source: './plugins/selected', + }, + ], + }), + }, + { + name: 'plugins/selected/.claude-plugin/plugin.json', + content: JSON.stringify({ + name: 'selected', + version: '1.0.0', + }), + }, + ]); + mockHttpsResponses(archive); + const extension = createExtension({ + name: 'selected', + version: '1.0.0', + installMetadata: { + type: 'archive-url', + source: 'https://example.com/catalog.zip', + pluginName: 'selected', + pluginSourceKind: 'marketplace-entry', + }, + }); + 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.UP_TO_DATE); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + it('should return UP_TO_DATE for archive URL extension with same version', async () => { const tempDir = await fs.mkdtemp( path.join(os.tmpdir(), 'archive-url-update-test-'), diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 2792d75418b..36a23ffd58c 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -350,6 +350,12 @@ export async function checkForExtensionUpdate( if (installMetadata.source.startsWith('upload:')) { return ExtensionUpdateState.NOT_UPDATABLE; } + // A local marketplace can select content fetched from a separate remote + // repository. The outer local path does not pin or version that nested + // content, so an automatic update sweep must not refetch it implicitly. + if (installMetadata.externalContent === true) { + return ExtensionUpdateState.NOT_UPDATABLE; + } let latestConfig: ExtensionConfig | undefined; let tempDir: string | undefined; let convertedDir: string | undefined; @@ -364,13 +370,18 @@ export async function checkForExtensionUpdate( signal?.throwIfAborted(); extensionDir = tempDir; } - if (tempDir !== undefined || installMetadata.originSource === 'Qoder') { + if ( + tempDir !== undefined || + installMetadata.originSource === 'Qoder' || + installMetadata.pluginSourceKind !== undefined + ) { const sourceBeforeConversion = extensionDir; const converted = await convertCompatibleExtension( sourceBeforeConversion, installMetadata.pluginName, installMetadata.networkPolicy, signal, + installMetadata.pluginSourceKind, ); extensionDir = converted.extensionDir; if (extensionDir !== sourceBeforeConversion) { @@ -411,6 +422,9 @@ export async function checkForExtensionUpdate( return checkNpmUpdate(installMetadata, signal); } if (installMetadata?.type === 'archive-url') { + if (installMetadata.externalContent === true) { + return ExtensionUpdateState.NOT_UPDATABLE; + } let tempDir: string | undefined; let convertedDir: string | undefined; try { @@ -423,6 +437,7 @@ export async function checkForExtensionUpdate( installMetadata.pluginName, installMetadata.networkPolicy, signal, + installMetadata.pluginSourceKind, ); const extensionDir = converted.extensionDir; if (extensionDir !== tempDir) { diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index 9be144c6fda..dd37b37ad0f 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -108,6 +108,18 @@ describe('parseInstallSource', () => { expect(result.source).toBe('https://github.com/owner/repo'); expect(result.type).toBe('git'); expect(result.pluginName).toBe('my-plugin'); + expect(result.pluginSourceKind).toBe('marketplace-entry'); + }); + + it('preserves an explicit root-plugin source kind', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource('owner/repo:alias', { + pluginSourceKind: 'extension-root', + }); + + expect(result.pluginName).toBe('alias'); + expect(result.pluginSourceKind).toBe('extension-root'); }); it('should handle owner/repo with dashes and underscores', async () => { @@ -156,6 +168,40 @@ describe('parseInstallSource', () => { expect(result.pluginName).toBeUndefined(); }); + it('should not treat a trailing HTTPS port as a plugin name', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource('https://example.com:8080', { + pluginSourceKind: 'marketplace-entry', + }); + + expect(result.source).toBe('https://example.com:8080'); + expect(result.pluginName).toBeUndefined(); + }); + + it('preserves a non-numeric HTTPS alias without a path', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource( + 'https://example.com:root-plugin', + ); + + expect(result.source).toBe('https://example.com'); + expect(result.pluginName).toBe('root-plugin'); + }); + + it('parses an all-digit HTTPS marketplace selector after the URL path', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource( + 'https://github.com/owner/repo:2048', + ); + + expect(result.source).toBe('https://github.com/owner/repo'); + expect(result.pluginName).toBe('2048'); + expect(result.pluginSourceKind).toBe('marketplace-entry'); + }); + it('should parse an uppercase HTTPS URL scheme as a git source', async () => { // Mock stat to fail (not a local path) vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); @@ -222,6 +268,53 @@ describe('parseInstallSource', () => { expect(result.type).toBe('git'); expect(result.pluginName).toBe('my-plugin'); }); + + it('parses a digit-leading direct-root alias without mistaking it for a port', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource( + 'git@github.com:owner/repo.git:2048-game', + { pluginSourceKind: 'extension-root' }, + ); + + expect(result.source).toBe('git@github.com:owner/repo.git'); + expect(result.pluginName).toBe('2048-game'); + expect(result.pluginSourceKind).toBe('extension-root'); + }); + + it('parses a digit-leading sso direct-root alias', async () => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource('sso://team/repo:2048-game', { + pluginSourceKind: 'extension-root', + }); + + expect(result.source).toBe('sso://team/repo'); + expect(result.pluginName).toBe('2048-game'); + expect(result.pluginSourceKind).toBe('extension-root'); + }); + + it.each([ + [ + 'git@github.com:owner/repo.git:2048', + 'git@github.com:owner/repo.git', + '2048', + ], + ['sso://team/repo:2049', 'sso://team/repo', '2049'], + ])( + 'parses an all-digit direct-root alias in %s', + async (installSource, source, pluginName) => { + vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT')); + + const result = await parseInstallSource(installSource, { + pluginSourceKind: 'extension-root', + }); + + expect(result.source).toBe(source); + expect(result.pluginName).toBe(pluginName); + expect(result.pluginSourceKind).toBe('extension-root'); + }, + ); }); describe('local path parsing', () => { diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 5444b18090f..e77136a84ac 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -41,7 +41,7 @@ export interface MarketplaceInstallResult { * - It's not part of a URL scheme (http://, https://, git@, sso://) * - It appears after the repo portion */ -function parseSourceAndPluginName(source: string): { +export function parseSourceAndPluginName(source: string): { repo: string; pluginName?: string; } { @@ -63,11 +63,18 @@ function parseSourceAndPluginName(source: string): { if (lastColonIndex !== -1) { // Check if what follows the colon looks like a pluginName (not a port number or path) const potentialPluginName = afterScheme.substring(lastColonIndex + 1); - // Plugin name should not contain '/' and should not be a number (port) + const separatorIndex = scheme.length + lastColonIndex; + const firstPathSlashIndex = source.indexOf('/', scheme.length); + const isHttpPort = + (scheme === 'http://' || scheme === 'https://') && + /^\d+$/.test(potentialPluginName) && + (firstPathSlashIndex === -1 || separatorIndex < firstPathSlashIndex); + // HTTP(S) ports appear in the authority, before the first path slash. + // A suffix after the path is an appended plugin name, even if numeric. if ( potentialPluginName && !potentialPluginName.includes('/') && - !/^\d+/.test(potentialPluginName) + !isHttpPort ) { repoEndIndex = scheme.length + lastColonIndex; hasPluginName = true; @@ -411,6 +418,7 @@ export async function parseInstallSource( source: string, options: { networkPolicy?: ExtensionInstallMetadata['networkPolicy']; + pluginSourceKind?: ExtensionInstallMetadata['pluginSourceKind']; } = {}, ): Promise { // Step 1: Parse source into repo and optional pluginName @@ -506,6 +514,11 @@ export async function parseInstallSource( if (options.networkPolicy) { installMetadata.networkPolicy = options.networkPolicy; } + const pluginSourceKind = + options.pluginSourceKind ?? (pluginName ? 'marketplace-entry' : undefined); + if (pluginSourceKind) { + installMetadata.pluginSourceKind = pluginSourceKind; + } return installMetadata; } diff --git a/packages/core/src/extension/qoder-converter.test.ts b/packages/core/src/extension/qoder-converter.test.ts index d6ca4faec9d..a382e643d7f 100644 --- a/packages/core/src/extension/qoder-converter.test.ts +++ b/packages/core/src/extension/qoder-converter.test.ts @@ -113,6 +113,35 @@ describe('convertQoderPlugin', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + it('preserves hook root variables for installation-time hydration', async () => { + writeManifest({ + name: 'sample-qoder-plugin', + hooks: { + SessionStart: [ + { + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/start.sh', + }, + ], + }, + ], + }, + }); + + const result = await convertQoderPlugin(root); + + expect( + ( + result.config.hooks?.['SessionStart']?.[0]?.hooks?.[0] as { + command?: string; + } + )?.command, + ).toBe('${CLAUDE_PLUGIN_ROOT}/scripts/start.sh'); + 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( diff --git a/packages/core/src/extension/qoder-converter.ts b/packages/core/src/extension/qoder-converter.ts index fb5db93bc39..6866f45064d 100644 --- a/packages/core/src/extension/qoder-converter.ts +++ b/packages/core/src/extension/qoder-converter.ts @@ -196,12 +196,16 @@ function resolveContextFiles( export async function convertQoderPlugin( extensionDir: string, + signal?: AbortSignal, ): Promise<{ config: ExtensionConfig; convertedDir: string }> { + signal?.throwIfAborted(); const config = loadQoderConfig(extensionDir); config.mcpServers = resolveMcpServers(extensionDir, config.mcpServers); const converted = await buildQwenExtensionFromPlugin( extensionDir, config as ClaudePluginConfig, + true, + signal, ); const contextFileName = resolveContextFiles( converted.convertedDir, diff --git a/packages/core/src/extension/sourceRegistry.test.ts b/packages/core/src/extension/sourceRegistry.test.ts index d13311cda5a..c4c46adcbda 100644 --- a/packages/core/src/extension/sourceRegistry.test.ts +++ b/packages/core/src/extension/sourceRegistry.test.ts @@ -14,8 +14,13 @@ import { discoverPlugins, type ExtensionSource, } from './sourceRegistry.js'; -import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import { + loadMarketplaceConfigFromSource, + parseSourceAndPluginName, +} from './marketplace.js'; import type { ClaudeMarketplaceConfig } from './claude-converter.js'; +import { convertCompatibleExtension } from './extension-converter.js'; +import { QODER_PLUGIN_MANIFEST } from './qoder-converter.js'; vi.mock('./marketplace.js', async (importOriginal) => { const actual = await importOriginal(); @@ -181,6 +186,7 @@ describe('discoverPlugins', () => { expect(pdf.installed).toBe(false); expect(pdf.homepage).toBe('https://example.com/pdf'); expect(pdf.installSource).toBe('anthropics/skills:pdf'); + expect(pdf.pluginSourceKind).toBe('marketplace-entry'); expect(discovered.find((p) => p.name === 'docx')!.installed).toBe(true); }); @@ -294,6 +300,72 @@ describe('discoverPlugins', () => { version: '1.0.0', source: { source: 'url', url: 'https://example.com/p.tgz' }, }, + { + name: 'https-root', + version: '1.0.0', + source: 'https://github.com/someone/root-plugin', + }, + { + name: 'url-github-root', + version: '1.0.0', + source: { + source: 'url', + url: 'https://github.com/someone/other-root-plugin', + }, + }, + { + name: 'selected-string-plugin', + version: '1.0.0', + source: 'someone/repo:selected', + }, + { + name: 'selected-url-plugin', + version: '1.0.0', + source: { + source: 'url', + url: 'https://github.com/someone/repo:selected', + }, + }, + { + name: 'port-root', + version: '1.0.0', + source: 'https://example.com:8443/root-plugin', + }, + { + name: 'port-selected', + version: '1.0.0', + source: 'https://example.com:8443/repo:selected', + }, + { + name: 'bare-root', + version: '1.0.0', + source: 'someone/direct-root', + }, + { + name: '2048-game', + version: '1.0.0', + source: 'git@github.com:someone/direct-root.git', + }, + { + name: '2048-sso', + version: '1.0.0', + source: 'sso://team/direct-root', + }, + { + name: '2048', + version: '1.0.0', + source: 'git@github.com:someone/numeric-root.git', + }, + { + name: '4096', + version: '1.0.0', + source: { source: 'github', repo: 'someone/numeric-https-root' }, + }, + { + name: 'embedded-numeric', + version: '1.0.0', + source: 'someone/repo:2048', + }, ]), ); @@ -303,17 +375,242 @@ describe('discoverPlugins', () => { ); expect(discovered.find((p) => p.name === 'gh-plugin')!.installSource).toBe( - 'someone/repo:gh-plugin', + 'https://github.com/someone/repo:gh-plugin', ); + expect( + discovered.find((p) => p.name === 'gh-plugin')!.pluginSourceKind, + ).toBe('extension-root'); expect(discovered.find((p) => p.name === 'url-plugin')!.installSource).toBe( 'https://example.com/p.tgz', ); + expect( + discovered.find((p) => p.name === 'url-plugin')!.pluginSourceKind, + ).toBe('extension-root'); + expect(discovered.find((p) => p.name === 'https-root')!.installSource).toBe( + 'https://github.com/someone/root-plugin', + ); + expect( + discovered.find((p) => p.name === 'https-root')!.pluginSourceKind, + ).toBe('extension-root'); + expect( + discovered.find((p) => p.name === 'url-github-root')!.installSource, + ).toBe('https://github.com/someone/other-root-plugin'); + expect( + discovered.find((p) => p.name === 'url-github-root')!.pluginSourceKind, + ).toBe('extension-root'); + expect( + discovered.find((p) => p.name === 'selected-string-plugin')! + .installSource, + ).toBe('https://github.com/someone/repo:selected'); + expect( + discovered.find((p) => p.name === 'selected-string-plugin')! + .pluginSourceKind, + ).toBe('marketplace-entry'); + expect( + discovered.find((p) => p.name === 'selected-url-plugin')!.installSource, + ).toBe('https://github.com/someone/repo:selected'); + expect( + discovered.find((p) => p.name === 'selected-url-plugin')! + .pluginSourceKind, + ).toBe('marketplace-entry'); + expect(discovered.find((p) => p.name === 'port-root')!.installSource).toBe( + 'https://example.com:8443/root-plugin', + ); + expect( + discovered.find((p) => p.name === 'port-root')!.pluginSourceKind, + ).toBe('extension-root'); + expect( + discovered.find((p) => p.name === 'port-selected')!.installSource, + ).toBe('https://example.com:8443/repo:selected'); + expect( + discovered.find((p) => p.name === 'port-selected')!.pluginSourceKind, + ).toBe('marketplace-entry'); + expect(discovered.find((p) => p.name === 'bare-root')).toMatchObject({ + installSource: 'https://github.com/someone/direct-root:bare-root', + pluginSourceKind: 'extension-root', + }); + expect(discovered.find((p) => p.name === '2048-game')).toMatchObject({ + installSource: 'git@github.com:someone/direct-root.git:2048-game', + pluginSourceKind: 'extension-root', + }); + expect(discovered.find((p) => p.name === '2048-sso')).toMatchObject({ + installSource: 'sso://team/direct-root:2048-sso', + pluginSourceKind: 'extension-root', + }); + expect( + parseSourceAndPluginName( + discovered.find((p) => p.name === '2048-game')!.installSource, + ), + ).toEqual({ + repo: 'git@github.com:someone/direct-root.git', + pluginName: '2048-game', + }); + expect( + parseSourceAndPluginName( + discovered.find((p) => p.name === '2048-sso')!.installSource, + ), + ).toEqual({ + repo: 'sso://team/direct-root', + pluginName: '2048-sso', + }); + expect(discovered.find((p) => p.name === '2048')).toMatchObject({ + installSource: 'git@github.com:someone/numeric-root.git:2048', + pluginSourceKind: 'extension-root', + }); + expect( + parseSourceAndPluginName( + discovered.find((p) => p.name === '2048')!.installSource, + ), + ).toEqual({ + repo: 'git@github.com:someone/numeric-root.git', + pluginName: '2048', + }); + const numericHttpsRoot = discovered.find((p) => p.name === '4096')!; + expect(numericHttpsRoot).toMatchObject({ + installSource: 'https://github.com/someone/numeric-https-root:4096', + pluginSourceKind: 'extension-root', + }); + expect(parseSourceAndPluginName(numericHttpsRoot.installSource)).toEqual({ + repo: 'https://github.com/someone/numeric-https-root', + pluginName: '4096', + }); + const embeddedNumeric = discovered.find( + (p) => p.name === 'embedded-numeric', + )!; + expect(embeddedNumeric).toMatchObject({ + installSource: 'https://github.com/someone/repo:2048', + pluginSourceKind: 'marketplace-entry', + }); + expect(parseSourceAndPluginName(embeddedNumeric.installSource)).toEqual({ + repo: 'https://github.com/someone/repo', + pluginName: '2048', + }); + }); + + it.each([ + ['a structured GitHub source without repo', { source: 'github' }], + ['a structured URL source without url', { source: 'url' }], + ])('keeps valid siblings when %s is malformed', async (_, source) => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Remote', [ + { + name: 'malformed', + version: '1.0.0', + source: source as never, + }, + { + name: 'valid', + version: '1.0.0', + source: 'https://example.com/valid.tgz', + }, + ]), + ); + + const discovered = await discoverPlugins( + [{ name: 'Remote', source: 'https://x/m.json', type: 'http' }], + new Set(), + ); + + expect(discovered).toHaveLength(2); + expect( + discovered.find((plugin) => plugin.name === 'malformed'), + ).toMatchObject({ + installSource: '', + pluginSourceKind: 'extension-root', + }); + expect(discovered.find((plugin) => plugin.name === 'valid')).toMatchObject({ + installSource: 'https://example.com/valid.tgz', + pluginSourceKind: 'extension-root', + }); + }); + + it('keeps a structured GitHub Qoder plugin installable as a direct root', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Remote', [ + { + name: 'qoder-alias', + version: '1.0.0', + source: { source: 'github', repo: 'someone/qoder-plugin' }, + }, + ]), + ); + const [plugin] = await discoverPlugins( + [{ name: 'Remote', source: 'https://x/m.json', type: 'http' }], + new Set(), + ); + const parsed = parseSourceAndPluginName(plugin.installSource); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qoder-discovery-')); + let convertedDir: string | undefined; + + try { + fs.mkdirSync(path.join(root, '.qoder-plugin'), { recursive: true }); + fs.writeFileSync( + path.join(root, QODER_PLUGIN_MANIFEST), + JSON.stringify({ name: 'qoder-root', version: '1.2.3' }), + 'utf-8', + ); + + const converted = await convertCompatibleExtension( + root, + parsed.pluginName, + undefined, + undefined, + plugin.pluginSourceKind, + ); + convertedDir = converted.extensionDir; + + expect(plugin).toMatchObject({ + installSource: 'https://github.com/someone/qoder-plugin:qoder-alias', + pluginSourceKind: 'extension-root', + }); + expect(converted.originSource).toBe('Qoder'); + expect( + JSON.parse( + fs.readFileSync( + path.join(converted.extensionDir, 'qwen-extension.json'), + 'utf-8', + ), + ), + ).toMatchObject({ name: 'qoder-root', version: '1.2.3' }); + } finally { + if (convertedDir && convertedDir !== root) { + fs.rmSync(convertedDir, { recursive: true, force: true }); + } + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('marks a direct-root alias installed by its source identity when names differ', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Remote', [ + { + name: 'catalog-alias', + version: '1.0.0', + source: { source: 'github', repo: 'someone/plugin-root' }, + }, + ]), + ); + + const [plugin] = await discoverPlugins( + [{ name: 'Remote', source: 'https://x/m.json', type: 'http' }], + new Set([ + JSON.stringify({ + source: 'https://github.com/someone/plugin-root', + pluginSourceKind: 'extension-root', + }), + ]), + ); + + expect(plugin).toMatchObject({ + name: 'catalog-alias', + installed: true, + pluginSourceKind: 'extension-root', + }); }); it('rejects local-path sources from a remote (http) marketplace', async () => { // A hostile remote marketplace must not be able to point the installer at a - // local filesystem path — via either the bare-string source or the - // structured { source: 'url' } form. Both fall back to the plugin name. + // local filesystem path through any supported source shape. vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( config('Remote', [ { name: 'abs', version: '1.0.0', source: '/etc/passwd' }, @@ -329,6 +626,21 @@ describe('discoverPlugins', () => { version: '1.0.0', source: { source: 'url', url: '../escape' }, }, + { + name: 'githubabs', + version: '1.0.0', + source: { source: 'github', repo: '/home/user/secrets' }, + }, + { + name: 'githubrel', + version: '1.0.0', + source: { source: 'github', repo: 'foo/bar/baz' }, + }, + { + name: 'githubcwd', + version: '1.0.0', + source: { source: 'github', repo: 'foo/bar' }, + }, { name: 'urlok', version: '1.0.0', @@ -344,17 +656,43 @@ describe('discoverPlugins', () => { const src = (name: string) => discovered.find((p) => p.name === name)!.installSource; - // String local paths fall back to the bare plugin name (no redirect). - expect(src('abs')).toBe('abs'); - expect(src('rel')).toBe('rel'); - expect(src('home')).toBe('home'); + // Rejected local redirects remain visible but are not installable. + expect(src('abs')).toBe(''); + expect(src('rel')).toBe(''); + expect(src('home')).toBe(''); // { source: 'url' } local paths are rejected too (previously bypassed). - expect(src('urlabs')).toBe('urlabs'); - expect(src('urlrel')).toBe('urlrel'); + expect(src('urlabs')).toBe(''); + expect(src('urlrel')).toBe(''); + expect(src('githubabs')).toBe(''); + expect(src('githubrel')).toBe(''); + // owner/repo is normalized before it reaches parseInstallSource, whose + // filesystem-first resolution would otherwise select ./foo/bar. + expect(src('githubcwd')).toBe('https://github.com/foo/bar:githubcwd'); // A genuine remote URL is preserved. expect(src('urlok')).toBe('https://example.com/p.tgz'); }); + it('marks a sourceless http entry as non-installable', async () => { + vi.mocked(loadMarketplaceConfigFromSource).mockResolvedValue( + config('Remote', [{ name: 'root-plugin', version: '1.0.0' }]), + ); + + const [plugin] = await discoverPlugins( + [ + { + name: 'Remote', + source: 'https://github.com/someone/root-plugin', + type: 'http', + }, + ], + new Set(), + ); + expect(plugin).toMatchObject({ + installSource: '', + pluginSourceKind: 'extension-root', + }); + }); + it('skips sources that fail to load without throwing', async () => { vi.mocked(loadMarketplaceConfigFromSource).mockImplementation( async (source: string) => { diff --git a/packages/core/src/extension/sourceRegistry.ts b/packages/core/src/extension/sourceRegistry.ts index 6a3dfbcaa2f..19a2db4eea2 100644 --- a/packages/core/src/extension/sourceRegistry.ts +++ b/packages/core/src/extension/sourceRegistry.ts @@ -10,9 +10,15 @@ import { atomicWriteFileSync } from '../utils/atomicFileWrite.js'; import { stripAnsiAndControl } from '../utils/textUtils.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { redactUrlCredentials } from './redaction.js'; -import { loadMarketplaceConfigFromSource } from './marketplace.js'; +import { + loadMarketplaceConfigFromSource, + parseSourceAndPluginName, +} from './marketplace.js'; import { quarantineCorruptFile } from './corruptFile.js'; -import type { ExtensionInstallMetadata } from '../config/config.js'; +import type { + ExtensionInstallMetadata, + ExtensionPluginSourceKind, +} from '../config/config.js'; import type { ClaudeMarketplaceConfig, ClaudeMarketplacePluginConfig, @@ -65,6 +71,10 @@ export interface DiscoveredPlugin { components?: DiscoveredPluginComponents; /** Source string suitable for `parseInstallSource`. */ installSource: string; + /** Whether `pluginName` selects a marketplace entry or names a root plugin. */ + pluginSourceKind?: ExtensionPluginSourceKind; + /** Stable install-source identity used to match direct-root aliases. */ + installIdentity?: string; /** Whether an extension with this name is already installed. */ installed: boolean; } @@ -148,7 +158,63 @@ function isGitHubHost(url: string): boolean { } function isOwnerRepoShorthand(source: string): boolean { - return /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(source); + const match = source.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/); + return Boolean( + match && + match[1] !== '.' && + match[1] !== '..' && + match[2] !== '.' && + match[2] !== '..', + ); +} + +/** + * Makes a plugin source from a remote marketplace unambiguously remote. + * + * `parseInstallSource` deliberately checks the filesystem before interpreting + * `owner/repo` shorthand. Leaving that shorthand untouched here would let a + * remote marketplace select a same-named directory below the process cwd. + */ +function normalizeRemotePluginSource(source: string): string | undefined { + const trimmed = source.trim(); + const { repo, pluginName } = parseSourceAndPluginName(trimmed); + if (parseExtensionSourceType(repo) === 'local') { + return undefined; + } + + const normalizedRepo = isOwnerRepoShorthand(repo) + ? `https://github.com/${repo}` + : repo; + return pluginName ? `${normalizedRepo}:${pluginName}` : normalizedRepo; +} + +function classifyRemotePluginSource( + source: string, + fallbackPluginName?: string, +): { + installSource: string; + pluginSourceKind: ExtensionPluginSourceKind; +} { + const parsed = parseSourceAndPluginName(source); + if (parsed.pluginName) { + return { + installSource: source, + pluginSourceKind: 'marketplace-entry', + }; + } + if (fallbackPluginName) { + return { + installSource: `${source}:${fallbackPluginName}`, + // The appended name is only an install alias for a direct plugin root. + // It must not turn a plain repo/transport source into a marketplace + // selector: Claude and Qoder roots do not contain marketplace.json. + pluginSourceKind: 'extension-root', + }; + } + return { + installSource: source, + pluginSourceKind: 'extension-root', + }; } /** @@ -161,43 +227,55 @@ function isOwnerRepoShorthand(source: string): boolean { function resolveInstallSource( marketplace: ExtensionSource, plugin: ClaudeMarketplacePluginConfig, -): string { +): { + installSource: string; + pluginSourceKind: ExtensionPluginSourceKind; +} { if (marketplace.type !== 'http') { - return `${marketplace.source}:${plugin.name}`; + return { + installSource: `${marketplace.source}:${plugin.name}`, + pluginSourceKind: 'marketplace-entry', + }; } const src = plugin.source; if (typeof src === 'string') { - // A remote marketplace must not be able to point the installer at an - // arbitrary local filesystem path (e.g. "/opt/secret" or "../../etc"). - if (path.isAbsolute(src) || src.startsWith('.') || src.startsWith('~')) { + const normalizedSource = normalizeRemotePluginSource(src); + if (!normalizedSource) { debugLogger.warn( `Ignoring local path source "${src}" from remote marketplace "${marketplace.source}".`, ); - return plugin.name; + return { installSource: '', pluginSourceKind: 'extension-root' }; } - return src.includes(':') ? src : `${src}:${plugin.name}`; + const isDirectUrl = /^https?:\/\//i.test(src.trim()); + return classifyRemotePluginSource( + normalizedSource, + isDirectUrl ? undefined : plugin.name, + ); } - if (src && src.source === 'github') { - return `${src.repo}:${plugin.name}`; + if (src && src.source === 'github' && typeof src.repo === 'string') { + const normalizedSource = normalizeRemotePluginSource(src.repo); + if (!normalizedSource) { + debugLogger.warn( + `Ignoring local path source "${src.repo}" from remote marketplace "${marketplace.source}".`, + ); + return { installSource: '', pluginSourceKind: 'extension-root' }; + } + return classifyRemotePluginSource(normalizedSource, plugin.name); } - if (src && src.source === 'url') { - // Same local-path guard as the string-source branch above: a remote - // marketplace must not be able to redirect the installer at a local - // filesystem path via the structured `{ source: 'url' }` form either. - if ( - typeof src.url === 'string' && - (path.isAbsolute(src.url) || - src.url.startsWith('.') || - src.url.startsWith('~')) - ) { + if (src && src.source === 'url' && typeof src.url === 'string') { + const normalizedSource = normalizeRemotePluginSource(src.url); + if (!normalizedSource) { debugLogger.warn( `Ignoring local path source "${src.url}" from remote marketplace "${marketplace.source}".`, ); - return plugin.name; + return { installSource: '', pluginSourceKind: 'extension-root' }; } - return src.url; + return classifyRemotePluginSource(normalizedSource); } - return plugin.name; + // A direct JSON document has no cloneable root for a missing/relative source. + // Keep the entry visible for provenance, but mark it non-installable instead + // of manufacturing a target that parseInstallSource cannot actually fetch. + return { installSource: '', pluginSourceKind: 'extension-root' }; } /** @@ -219,26 +297,60 @@ function sanitizeDisplay(text: string | undefined): string | undefined { function pluginsFromConfig( marketplace: ExtensionSource, config: ClaudeMarketplaceConfig, - installedNames: ReadonlySet, + installedKeys: ReadonlySet, ): DiscoveredPlugin[] { - return (config.plugins ?? []).map((plugin) => ({ - marketplaceName: sanitizeDisplay(config.name || marketplace.name), - name: sanitizeDisplay(plugin.name), - description: sanitizeDisplay(plugin.description), - // `version` and `lastUpdated` render in the pre-consent Discover detail via - // `t()` (no escaping), so they need the same scrubbing as the other - // untrusted display fields. `category` has no sink today but is wrapped for - // consistency / future-proofing. - version: sanitizeDisplay(plugin.version), - author: sanitizeDisplay(plugin.author?.name), - homepage: sanitizeDisplay(plugin.homepage), - category: sanitizeDisplay(plugin.category), - lastUpdated: sanitizeDisplay(pluginLastUpdated(plugin)), - installs: pluginInstalls(plugin), - components: pluginComponents(plugin), - installSource: resolveInstallSource(marketplace, plugin), - installed: installedNames.has(plugin.name), - })); + return (config.plugins ?? []).map((plugin) => { + const installTarget = resolveInstallSource(marketplace, plugin); + return { + marketplaceName: sanitizeDisplay(config.name || marketplace.name), + name: sanitizeDisplay(plugin.name), + description: sanitizeDisplay(plugin.description), + // `version` and `lastUpdated` render in the pre-consent Discover detail + // via `t()` (no escaping), so they need the same scrubbing as the other + // untrusted display fields. `category` has no sink today but is wrapped + // for consistency / future-proofing. + version: sanitizeDisplay(plugin.version), + author: sanitizeDisplay(plugin.author?.name), + homepage: sanitizeDisplay(plugin.homepage), + category: sanitizeDisplay(plugin.category), + lastUpdated: sanitizeDisplay(pluginLastUpdated(plugin)), + installs: pluginInstalls(plugin), + components: pluginComponents(plugin), + ...installTarget, + installIdentity: installTarget.installSource + ? discoveredPluginInstallIdentity( + installTarget.installSource, + installTarget.pluginSourceKind, + ) + : undefined, + installed: + installedKeys.has(plugin.name) || + (installTarget.installSource !== '' && + installedKeys.has( + discoveredPluginInstallIdentity( + installTarget.installSource, + installTarget.pluginSourceKind, + ), + )), + }; + }); +} + +export function discoveredPluginInstallIdentity( + installSource: string, + pluginSourceKind?: ExtensionPluginSourceKind, +): string { + const { repo, pluginName } = parseSourceAndPluginName(installSource); + const normalizedSource = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/.test(repo) + ? `https://github.com/${repo}` + : repo; + return JSON.stringify({ + source: normalizedSource, + ...(pluginSourceKind === 'marketplace-entry' && pluginName + ? { pluginName } + : {}), + pluginSourceKind, + }); } /** @@ -324,7 +436,7 @@ export class SourceRegistryStore { */ export async function discoverPlugins( sources: readonly ExtensionSource[], - installedNames: ReadonlySet, + installedKeys: ReadonlySet, networkPolicy?: ExtensionInstallMetadata['networkPolicy'], ): Promise { const results = await Promise.all( @@ -342,7 +454,7 @@ export async function discoverPlugins( ); return []; } - return pluginsFromConfig(marketplace, config, installedNames); + return pluginsFromConfig(marketplace, config, installedKeys); } catch (error) { debugLogger.error( `Failed to discover plugins from ${redactUrlCredentials(