Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions packages/core/src/extension/claude-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
48 changes: 22 additions & 26 deletions packages/core/src/extension/claude-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The function's JSDoc (lines 550-554) is now outdated. It still says "If a resource is already in the destination folder, it will be skipped", but this fix reverses that behavior — same-name root directory resources are now actively copied/flattened into the destination folder, not skipped. Consider updating the JSDoc to reflect the new behavior.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

// 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('**/*', {
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading