Skip to content
Merged
13 changes: 12 additions & 1 deletion packages/cli/src/services/prompt-processors/shellProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getShellConfiguration,
ShellExecutionService,
flatMapTextParts,
checkArgumentSafety,
} from '@qwen-code/qwen-code-core';

import type { CommandContext } from '../../ui/commands/types.js';
Expand Down Expand Up @@ -99,6 +100,16 @@ export class ShellProcessor implements IPromptProcessor {
const { shell } = getShellConfiguration();
const userArgsEscaped = escapeShellArg(userArgsRaw, shell);

// Check safety of the value that will be used for $ARGUMENTS (after removing outer quotes)
let userArgsForArgumentsPlaceholder = userArgsRaw.replace(
/^'([\s\S]*?)'$/,
'$1',
);
const argumentSafety = checkArgumentSafety(userArgsForArgumentsPlaceholder);
if (!argumentSafety.isSafe) {
userArgsForArgumentsPlaceholder = userArgsEscaped;
}

const resolvedInjections: ResolvedShellInjection[] = injections.map(
(injection) => {
const command = injection.content;
Expand All @@ -109,7 +120,7 @@ export class ShellProcessor implements IPromptProcessor {

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

describe('convertClaudeToQwenConfig', () => {
it('should convert basic Claude config', () => {
Expand Down Expand Up @@ -510,64 +511,67 @@ describe('convertClaudePluginPackage', () => {
// 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 });
describe('performVariableReplacement for Claude extensions', () => {
let testDir: string;

// Create marketplace.json with hooks defined directly
const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin');
fs.mkdirSync(marketplaceDir, { recursive: true });
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-var-test-'));
});

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',
},
],
},
],
},
},
],
};
afterEach(() => {
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true });
}
});

fs.writeFileSync(
path.join(marketplaceDir, 'marketplace.json'),
JSON.stringify(marketplaceConfig, null, 2),
'utf-8',
);
it('should replace .claude with .qwen in shell scripts', () => {
const extDir = path.join(testDir, 'ext-sh');
fs.mkdirSync(extDir, { recursive: true });

// Execute: Convert the plugin
const result = await convertClaudePluginPackage(
pluginSourceDir,
'direct-hooks-plugin',
);
const shContent = `#!/bin/bash
CONFIG_DIR="$HOME/.claude/config"
CACHE_DIR="~/.claude/cache"
LOCAL_DIR="./.claude/local"`;
fs.writeFileSync(path.join(extDir, 'setup.sh'), shContent, 'utf-8');

// 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',
);
performVariableReplacement(extDir);

// Clean up converted directory
fs.rmSync(result.convertedDir, { recursive: true, force: true });
const result = fs.readFileSync(path.join(extDir, 'setup.sh'), 'utf-8');
expect(result).toContain('$HOME/.qwen/config');
expect(result).toContain('~/.qwen/cache');
expect(result).toContain('./.qwen/local');
expect(result).not.toContain('.claude');
});

it('should replace role with type in shell scripts', () => {
const extDir = path.join(testDir, 'ext-role');
fs.mkdirSync(extDir, { recursive: true });

const shContent = `#!/bin/bash
echo '{"role":"assistant","content":"hello"}'`;
fs.writeFileSync(path.join(extDir, 'process.sh'), shContent, 'utf-8');

performVariableReplacement(extDir);

const result = fs.readFileSync(path.join(extDir, 'process.sh'), 'utf-8');
expect(result).toContain('"type":"assistant"');
expect(result).not.toContain('"role":"assistant"');
});

it('should update transcript parsing logic in shell scripts', () => {
const extDir = path.join(testDir, 'ext-transcript');
fs.mkdirSync(extDir, { recursive: true });

const shContent = `#!/bin/bash
echo "$transcript" | jq '.message.content | map(select(.type == "text"))'`;
fs.writeFileSync(path.join(extDir, 'parse.sh'), shContent, 'utf-8');

performVariableReplacement(extDir);

const result = fs.readFileSync(path.join(extDir, 'parse.sh'), 'utf-8');
expect(result).toContain('.message.parts | map(select(has("text")))');
expect(result).not.toContain('.message.content');
});
});
117 changes: 1 addition & 116 deletions packages/core/src/extension/claude-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,117 +30,6 @@ 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 Down Expand Up @@ -619,14 +508,10 @@ export async function convertClaudePluginPackage(
}
}

// Step 9.1: Convert collected agent files from Claude format to Qwen format
// Step 9: 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