diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index a55a8bbd8c..ae1160ed01 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -525,6 +525,109 @@ describe('convertClaudePluginPackage', () => { // Clean up converted directory fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + + it('should collect resources from same-name root directories (e.g., ./commands/ -> commands/)', async () => { + // This tests the fix for #4452: when marketplace config specifies + // resources like ["./commands/"], and the plugin source has a root-level + // "commands/" directory, the contents should be collected correctly + // instead of being skipped. + const pluginSourceDir = path.join(testDir, 'plugin-same-name-dirs'); + fs.mkdirSync(pluginSourceDir, { recursive: true }); + + // Create commands/, skills/, agents/ directories at plugin root + const commandsDir = path.join(pluginSourceDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync( + path.join(commandsDir, 'generate.md'), + '---\nname: generate\n---\nGenerate wiki', + 'utf-8', + ); + fs.writeFileSync( + path.join(commandsDir, 'ask.md'), + '---\nname: ask\n---\nAsk wiki', + 'utf-8', + ); + + const skillsDir = path.join(pluginSourceDir, 'skills'); + fs.mkdirSync(path.join(skillsDir, 'wiki-architect'), { recursive: true }); + fs.writeFileSync( + path.join(skillsDir, 'wiki-architect', 'SKILL.md'), + '# Wiki Architect Skill', + 'utf-8', + ); + + const agentsDir = path.join(pluginSourceDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, 'wiki-architect.md'), + '---\nname: wiki-architect\ndescription: Architect agent\n---\nSystem prompt', + 'utf-8', + ); + + // Create marketplace.json pointing to same-name directories + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + + const pluginJson = { name: 'deep-wiki', version: '2.0.0' }; + fs.writeFileSync( + path.join(marketplaceDir, 'plugin.json'), + JSON.stringify(pluginJson, null, 2), + 'utf-8', + ); + + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test', email: 'test@example.com' }, + plugins: [ + { + name: 'deep-wiki', + version: '2.0.0', + source: './', + strict: true, + commands: ['./commands/'], + skills: ['./skills/'], + agents: ['./agents/wiki-architect.md'], + }, + ], + }; + + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + + // Act + const result = await convertClaudePluginPackage( + pluginSourceDir, + 'deep-wiki', + ); + + // Verify: commands were collected + const convertedCommandsDir = path.join(result.convertedDir, 'commands'); + expect(fs.existsSync(convertedCommandsDir)).toBe(true); + const cmdFiles = fs.readdirSync(convertedCommandsDir); + expect(cmdFiles).toContain('generate.md'); + expect(cmdFiles).toContain('ask.md'); + + // Verify: skills were collected + const convertedSkillsDir = path.join(result.convertedDir, 'skills'); + expect(fs.existsSync(convertedSkillsDir)).toBe(true); + expect( + fs.existsSync( + path.join(convertedSkillsDir, 'wiki-architect', 'SKILL.md'), + ), + ).toBe(true); + + // Verify: agents were collected + const convertedAgentsDir = path.join(result.convertedDir, 'agents'); + expect(fs.existsSync(convertedAgentsDir)).toBe(true); + const agentFiles = fs.readdirSync(convertedAgentsDir); + expect(agentFiles).toContain('wiki-architect.md'); + + // Clean up + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); }); describe('performVariableReplacement for Claude extensions', () => { diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 2df8b9df0d..4c757212bb 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -582,22 +582,17 @@ async function collectResources( const stat = fs.statSync(resolvedPath); if (stat.isDirectory()) { - // If it's a directory, check if it's already the destination folder const dirName = path.basename(resolvedPath); const parentDir = path.dirname(resolvedPath); - // If the directory is already named as the destination folder (e.g., 'commands') - // and it's at the plugin root level, skip it - if (dirName === destFolderName && parentDir === pluginRoot) { - debugLogger.debug( - `Skipping ${resolvedPath} as it's already in the correct location`, - ); - continue; - } - - // Determine destination: preserve the directory name - // e.g., ./skills/xlsx -> tmpDir/skills/xlsx/ - const finalDestDir = path.join(destDir, dirName); + // Determine destination: if the directory has the same name as the + // destination folder and sits at the plugin root (e.g., config says + // "./commands/" and pluginRoot has a commands/ dir), copy its contents + // directly into destDir rather than creating a nested subdirectory. + const finalDestDir = + dirName === destFolderName && parentDir === pluginRoot + ? destDir + : path.join(destDir, dirName); // Copy all files from the directory const files = await glob('**/*', { @@ -631,22 +626,23 @@ async function collectResources( fs.copyFileSync(srcFile, destFile); } } else { - // If it's a file, check if it's already in the destination folder + // If it's a file, preserve its relative path under the destination. + // e.g., ./agents/wiki-architect.md → destDir/wiki-architect.md const relativePath = path.relative(pluginRoot, resolvedPath); - - // Check if the file path starts with the destination folder name - // e.g., 'commands/test1.md' or 'commands/me/test.md' should be skipped const segments = relativePath.split(path.sep); - if (segments.length > 0 && segments[0] === destFolderName) { - debugLogger.debug( - `Skipping ${resolvedPath} as it's already in ${destFolderName}/`, - ); - continue; - } - // Copy the file to destination - const fileName = path.basename(resolvedPath); - const destFile = path.join(destDir, fileName); + // Strip the leading segment if it matches the destination folder name + // (e.g., 'agents/wiki-architect.md' → 'wiki-architect.md') + const destRelative = + segments.length > 1 && segments[0] === destFolderName + ? segments.slice(1).join(path.sep) + : path.basename(resolvedPath); + + const destFile = path.join(destDir, destRelative); + const destFileDir = path.dirname(destFile); + if (!fs.existsSync(destFileDir)) { + fs.mkdirSync(destFileDir, { recursive: true }); + } fs.copyFileSync(resolvedPath, destFile); } }