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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,9 @@ export class ShellProcessor implements IPromptProcessor {
return { ...injection, resolvedCommand: undefined };
}

const resolvedCommand = command.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsEscaped,
);
const resolvedCommand = command
.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}}
.replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS
return { ...injection, resolvedCommand };
},
);
Expand Down
137 changes: 137 additions & 0 deletions packages/core/src/extension/claude-converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type ClaudeMarketplacePluginConfig,
type ClaudeMarketplaceConfig,
} from './claude-converter.js';
import { HookType } from '../hooks/types.js';

describe('convertClaudeToQwenConfig', () => {
it('should convert basic Claude config', () => {
Expand Down Expand Up @@ -433,4 +434,140 @@ describe('convertClaudePluginPackage', () => {
// Clean up
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});

it('should convert hooks from Claude plugin format to Qwen format with variable substitution', async () => {
// Setup: Create a plugin with hooks in Claude format
const pluginSourceDir = path.join(testDir, 'plugin-with-hooks');
fs.mkdirSync(pluginSourceDir, { recursive: true });

// Create hooks directory with hooks.json in Claude format
const hooksDir = path.join(pluginSourceDir, 'hooks');
fs.mkdirSync(hooksDir, { recursive: true });

const hooksJson = {
hooks: {
PostToolUse: [
{
matcher: 'post-install-matcher', // Part of HookDefinition
sequential: true, // Part of HookDefinition
description: 'Run after installation',
hooks: [
// HookConfig[] array inside HookDefinition
{
type: HookType.Command,
command: '${CLAUDE_PLUGIN_ROOT}/scripts/post-install.sh',
},
],
},
],
},
};

fs.writeFileSync(
path.join(hooksDir, 'hooks.json'),
JSON.stringify(hooksJson),
'utf-8',
);

// Create marketplace.json
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });

const marketplaceConfig: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{
name: 'hooks-plugin',
version: '1.0.0',
source: './',
strict: false,
hooks: './hooks/hooks.json', // Reference hooks from file
},
],
};

fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);

// Execute: Convert the plugin
const result = await convertClaudePluginPackage(
pluginSourceDir,
'hooks-plugin',
);

// Verify: The converted config should contain processed hooks
expect(result.config.hooks).toBeDefined();
expect(result.config.hooks!['PostToolUse']).toHaveLength(1);
// Check that the variable was substituted
expect(result.config.hooks!['PostToolUse']![0].hooks![0].command).toBe(
`${pluginSourceDir}/scripts/post-install.sh`,
);

// Clean up converted directory
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});

it('should handle hooks defined directly in marketplace config', async () => {
// Setup: Create a plugin with hooks defined directly in marketplace config
const pluginSourceDir = path.join(testDir, 'direct-hooks-plugin');
fs.mkdirSync(pluginSourceDir, { recursive: true });

// Create marketplace.json with hooks defined directly
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });

const marketplaceConfig: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{
name: 'direct-hooks-plugin',
version: '1.0.0',
source: './',
strict: false,
hooks: {
PreToolUse: [
{
matcher: '*', // Part of HookDefinition
sequential: true, // Part of HookDefinition
hooks: [
// HookConfig[] array inside HookDefinition
{
type: HookType.Command,
command: 'npm install',
},
],
},
],
},
},
],
};

fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);

// Execute: Convert the plugin
const result = await convertClaudePluginPackage(
pluginSourceDir,
'direct-hooks-plugin',
);

// Verify: The converted config should contain the hooks
expect(result.config.hooks).toBeDefined();
expect(result.config.hooks!['PreToolUse']).toHaveLength(1);
expect(result.config.hooks!['PreToolUse']![0].hooks![0].command).toBe(
'npm install',
);

// Clean up converted directory
fs.rmSync(result.convertedDir, { recursive: true, force: true });
});
});
172 changes: 167 additions & 5 deletions packages/core/src/extension/claude-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
ExtensionInstallMetadata,
MCPServerConfig,
} 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 } from './gemini-converter.js';
Expand All @@ -25,9 +26,121 @@ import {
} from '../utils/yaml-parser.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { normalizeContent } from '../utils/textUtils.js';
import { substituteHookVariables } from './variables.js';

const debugLogger = createDebugLogger('CLAUDE_CONVERTER');

/**
* Perform variable replacement in all markdown and shell script files of the extension.
* This is done during the conversion phase to avoid modifying files during every extension load.
* @param extensionPath - The path to the extension directory
*/
export function performVariableReplacement(extensionPath: string): void {
// Process markdown files
const mdGlobPattern = '**/*.md';
const mdGlobOptions = {
cwd: extensionPath,
nodir: true,
};

try {
const mdFiles = glob.sync(mdGlobPattern, mdGlobOptions);

for (const file of mdFiles) {
const filePath = path.join(extensionPath, file);

try {
const content = fs.readFileSync(filePath, 'utf8');

// Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path
const updatedContent = content.replace(
/\$\{CLAUDE_PLUGIN_ROOT\}/g,
extensionPath,
);

// Replace Markdown shell syntax ```! ... ``` with system-recognized !{...} syntax
// This regex finds code blocks with ! language identifier and captures their content
const updatedMdContent = updatedContent.replace(
/```!(?:\s*\n)?([\s\S]*?)\n*```/g,
'!{$1}',
);

// Only write if content was actually changed
if (updatedMdContent !== content) {
fs.writeFileSync(filePath, updatedMdContent, 'utf8');
debugLogger.debug(
`Updated variables and syntax in file: ${filePath}`,
);
}
} catch (error) {
debugLogger.warn(
`Failed to process file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
} catch (error) {
debugLogger.warn(
`Failed to scan markdown files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}

// Process shell script files
const scriptGlobPattern = '**/*.sh';
const scriptGlobOptions = {
cwd: extensionPath,
nodir: true,
};

try {
const scriptFiles = glob.sync(scriptGlobPattern, scriptGlobOptions);

for (const file of scriptFiles) {
const filePath = path.join(extensionPath, file);

try {
const content = fs.readFileSync(filePath, 'utf8');

// Replace references to "role":"assistant" with "type":"assistant" in shell scripts
const updatedScriptContent = content.replace(
/"role":"assistant"/g,
'"type":"assistant"',
);

// Replace transcript parsing logic to adapt to actual transcript structure
// Change from .message.content | map(select(.type == "text")) to .message.parts | map(select(has("text")))
const adaptedScriptContent = updatedScriptContent.replace(
/\.message\.content\s*\|\s*map\(select\(\.type\s*==\s*"text"\)\)/g,
'.message.parts | map(select(has("text")))',
);

// Replace references to ".claude" directory with ".qwen" in shell scripts
// Only match path references (e.g., ~/.claude/, $HOME/.claude, ./.claude/)
// Avoid matching URLs, comments, or string literals containing .claude
const finalScriptContent = adaptedScriptContent.replace(
/(\$\{?HOME\}?\/|~\/)?\.claude(\/|$)/g,
'$1.qwen$2',
);

// Only write if content was actually changed
if (finalScriptContent !== content) {
fs.writeFileSync(filePath, finalScriptContent, 'utf8');
debugLogger.debug(
`Updated transcript format and replaced .claude with .qwen in shell script: ${filePath}`,
);
}
} catch (error) {
debugLogger.warn(
`Failed to process shell script file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
} catch (error) {
debugLogger.warn(
`Failed to scan shell script files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

export interface ClaudePluginConfig {
name: string;
version: string;
Expand All @@ -40,7 +153,7 @@ export interface ClaudePluginConfig {
commands?: string | string[];
agents?: string | string[];
skills?: string | string[];
hooks?: string;
hooks?: string | { [K in HookEventName]?: HookDefinition[] };
mcpServers?: string | Record<string, MCPServerConfig>;
outputStyles?: string | string[];
lspServers?: string | Record<string, unknown>;
Expand Down Expand Up @@ -312,12 +425,21 @@ export function convertClaudeToQwenConfig(
}
}

// Warn about unsupported fields
// Parse hooks
let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined;
if (claudeConfig.hooks) {
debugLogger.warn(
`[Claude Converter] Hooks are not yet supported in ${claudeConfig.name}`,
);
if (typeof claudeConfig.hooks === 'string') {
// If it's a string, it's a file path, we handle it later in the conversion process
// hooks will be loaded from file path in the convertClaudePluginPackage function
} else {
// Assume it's already in the correct format
hooks = claudeConfig.hooks as { [K in HookEventName]?: HookDefinition[] };
}
} else {
hooks = undefined;
}

// Warn about unsupported fields
if (claudeConfig.outputStyles) {
debugLogger.warn(
`[Claude Converter] Output styles are not yet supported in ${claudeConfig.name}`,
Expand All @@ -329,6 +451,7 @@ export function convertClaudeToQwenConfig(
version: claudeConfig.version,
mcpServers,
lspServers: claudeConfig.lspServers,
hooks, // Assign the properly typed hooks variable
};
}

Expand Down Expand Up @@ -461,10 +584,49 @@ export async function convertClaudePluginPackage(
// Otherwise, keep the existing folder from pluginSource (default behavior)
}

// Step 7: Handle hooks from file paths if needed
if (mergedConfig.hooks && typeof mergedConfig.hooks === 'string') {
const hooksPath = path.isAbsolute(mergedConfig.hooks)
? mergedConfig.hooks
: path.join(pluginSource, mergedConfig.hooks);

if (fs.existsSync(hooksPath)) {
try {
const hooksContent = fs.readFileSync(hooksPath, 'utf-8');
const parsedHooks = JSON.parse(hooksContent);

// Check if the file has a top-level "hooks" property (like Claude plugins use)
// or if the entire file content is the hooks object
let hooksData;
if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') {
hooksData = parsedHooks.hooks as {
[K in HookEventName]?: HookDefinition[];
};
} else {
// Assume the entire file content is the hooks object
hooksData = parsedHooks as {
[K in HookEventName]?: HookDefinition[];
};
}

// Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT}
mergedConfig.hooks = substituteHookVariables(hooksData, pluginSource);
} catch (error) {
debugLogger.warn(
`Failed to parse hooks file ${hooksPath}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
}

// Step 9.1: Convert collected agent files from Claude format to Qwen format
const agentsDestDir = path.join(tmpDir, 'agents');
await convertAgentFiles(agentsDestDir);

// Step 9.2: Perform variable replacement in markdown and shell script files
// This is done during conversion to avoid modifying files during every extension load
performVariableReplacement(tmpDir);

// Step 10: Convert to Qwen format config
const qwenConfig = convertClaudeToQwenConfig(mergedConfig);

Expand Down
Loading
Loading