From b5186d3c8abfae5a091f9ee400a7c12a0acbdaa0 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Thu, 19 Mar 2026 12:02:54 +0800 Subject: [PATCH] improve to change extension path instead of tmp path and fix shell multiple input --- .../prompt-processors/shellProcessor.ts | 8 +- .../src/extension/claude-converter.test.ts | 79 +++++++ .../core/src/extension/claude-converter.ts | 117 +--------- .../core/src/extension/extensionManager.ts | 6 + packages/core/src/extension/variables.test.ts | 220 +++++++++++++++++- packages/core/src/extension/variables.ts | 116 +++++++++ 6 files changed, 427 insertions(+), 119 deletions(-) diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index a3e30bf66..39749e8f0 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -111,7 +111,13 @@ export class ShellProcessor implements IPromptProcessor { const resolvedCommand = command .replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}} - .replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS + .replaceAll( + '$ARGUMENTS', + userArgsRaw + .split(' ') + .map((arg) => escapeShellArg(arg, shell)) + .join(' '), + ); return { ...injection, resolvedCommand }; }, ); diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index c984b17bc..8a47a2919 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -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', () => { @@ -571,3 +572,81 @@ describe('convertClaudePluginPackage', () => { fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); }); + +describe('performVariableReplacement for Claude extensions', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-var-test-')); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should replace ${CLAUDE_PLUGIN_ROOT} in markdown files', () => { + const extDir = path.join(testDir, 'ext-md'); + fs.mkdirSync(extDir, { recursive: true }); + + const mdContent = `# Test Extension + Run \`\${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh\` to configure.`; + fs.writeFileSync(path.join(extDir, 'README.md'), mdContent, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'README.md'), 'utf-8'); + expect(result).toContain(`${extDir}/scripts/setup.sh`); + expect(result).not.toContain('${CLAUDE_PLUGIN_ROOT}'); + }); + + it('should replace .claude with .qwen in shell scripts', () => { + const extDir = path.join(testDir, 'ext-sh'); + fs.mkdirSync(extDir, { recursive: true }); + + 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'); + + performVariableReplacement(extDir); + + 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'); + }); +}); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index ff5ba72a9..9ef287827 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -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; @@ -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); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index d0382347e..e26f14962 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -30,6 +30,7 @@ import { INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, substituteHookVariables, + performVariableReplacement, } from './variables.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { @@ -987,6 +988,11 @@ export class ExtensionManager { await copyExtension(localSourcePath, destinationPath); } + // Perform variable replacement in extension files (e.g., ${CLAUDE_PLUGIN_ROOT}) for Claude extensions + if (originSource === 'Claude') { + performVariableReplacement(destinationPath); + } + const metadataString = JSON.stringify(installMetadata, null, 2); const metadataPath = path.join( destinationPath, diff --git a/packages/core/src/extension/variables.test.ts b/packages/core/src/extension/variables.test.ts index e8a1db714..5f611ad06 100644 --- a/packages/core/src/extension/variables.test.ts +++ b/packages/core/src/extension/variables.test.ts @@ -4,9 +4,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { expect, describe, it } from 'vitest'; -import { hydrateString, substituteHookVariables } from './variables.js'; +import { expect, describe, it, beforeEach, afterEach } from 'vitest'; +import { + hydrateString, + substituteHookVariables, + performVariableReplacement, +} from './variables.js'; import { HookType } from '../hooks/types.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; describe('hydrateString', () => { it('should replace a single variable', () => { @@ -194,3 +201,212 @@ describe('substituteHookVariables', () => { expect(result!['Stop']![0].hooks![0].command).toBe('echo "hello world"'); }); }); + +describe('performVariableReplacement', () => { + let testDir: string; + + beforeEach(() => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'var-replace-test-')); + }); + + afterEach(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should replace ${CLAUDE_PLUGIN_ROOT} in markdown files', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + + const mdContent = [ + '# README', + '', + 'Configuration file is at `${CLAUDE_PLUGIN_ROOT}/config.json`.', + 'Run `${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh` to initialize.', + ] + .join('\n') + .replace(/`\${CLAUDE_PLUGIN_ROOT}/g, '`${CLAUDE_PLUGIN_ROOT}'); + fs.writeFileSync(path.join(extDir, 'README.md'), mdContent, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'README.md'), 'utf-8'); + expect(result).toContain(`${extDir}/config.json`); + expect(result).toContain(`${extDir}/scripts/setup.sh`); + expect(result).not.toContain('${CLAUDE_PLUGIN_ROOT}'); + }); + + it('should convert ```! syntax to !{} in markdown files', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + + const mdContent = `## Commands + + \`\`\`! + npm install + npm run build + \`\`\` + + Some text. + + \`\`\`! + echo "Hello World" + \`\`\` + `; + fs.writeFileSync(path.join(extDir, 'guide.md'), mdContent, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'guide.md'), 'utf-8'); + expect(result).toContain('!{npm install\nnpm run build}'); + expect(result).toContain('!{echo "Hello World"}'); + expect(result).not.toContain('```!'); + }); + + it('should replace "role":"assistant" with "type":"assistant" in shell scripts', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + + const shContent = `#!/bin/bash + # Process response + echo '{"role":"assistant","content":"Hello"}' + echo '{"role":"user","content":"Hi"}' + echo '{"role":"assistant","content":"How can I help?"}' + `; + 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"'); + // Should not affect other roles + expect(result).toContain('"role":"user"'); + }); + + it('should update transcript parsing in shell scripts', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + + const shContent = `#!/bin/bash + # Parse transcript + jq '.message.content | map(select(.type == "text"))' <<< "$response" + `; + 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'); + }); + + it('should replace .claude with .qwen in shell scripts', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + + const shContent = [ + '#!/bin/bash', + 'HOME_CLAUDE="$HOME/.claude"', + 'CACHE_DIR="~/.claude/cache"', + 'LOCAL_DIR="./.claude/local"', + 'CONFIG="${CLAUDE_PLUGIN_ROOT}/.claude/config"', + '# Not replaced: https://example.com/.claude/page', + ] + .join('\n') + .replace('${CLAUDE_PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'); + fs.writeFileSync(path.join(extDir, 'setup.sh'), shContent, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'setup.sh'), 'utf-8'); + expect(result).toContain('$HOME/.qwen'); + expect(result).toContain('~/.qwen/cache'); + expect(result).toContain('./.qwen/local'); + expect(result).toContain('.qwen/config'); + // URL should not be affected + expect(result).toContain('https://example.com/.claude/page'); + }); + + it('should handle multiple markdown files', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.mkdirSync(path.join(extDir, 'docs'), { recursive: true }); + + fs.writeFileSync( + path.join(extDir, 'README.md'), + 'Path: `${CLAUDE_PLUGIN_ROOT}/readme`', + 'utf-8', + ); + fs.writeFileSync( + path.join(extDir, 'docs', 'guide.md'), + 'Path: `${CLAUDE_PLUGIN_ROOT}/docs/guide`', + 'utf-8', + ); + + performVariableReplacement(extDir); + + const readme = fs.readFileSync(path.join(extDir, 'README.md'), 'utf-8'); + const guide = fs.readFileSync( + path.join(extDir, 'docs', 'guide.md'), + 'utf-8', + ); + + expect(readme).toContain(`${extDir}/readme`); + expect(guide).toContain(`${extDir}/docs/guide`); + }); + + it('should handle multiple shell script files', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + fs.mkdirSync(path.join(extDir, 'scripts'), { recursive: true }); + + fs.writeFileSync( + path.join(extDir, 'setup.sh'), + 'echo "${CLAUDE_PLUGIN_ROOT}/setup"', + 'utf-8', + ); + fs.writeFileSync( + path.join(extDir, 'scripts', 'helper.sh'), + 'echo "${CLAUDE_PLUGIN_ROOT}/scripts/helper"', + 'utf-8', + ); + + performVariableReplacement(extDir); + + const setup = fs.readFileSync(path.join(extDir, 'setup.sh'), 'utf-8'); + const helper = fs.readFileSync( + path.join(extDir, 'scripts', 'helper.sh'), + 'utf-8', + ); + + expect(setup).toContain(`${extDir}/setup`); + expect(helper).toContain(`${extDir}/scripts/helper`); + }); + + it('should handle empty directories gracefully', () => { + const extDir = path.join(testDir, 'empty-ext'); + fs.mkdirSync(extDir, { recursive: true }); + + // Should not throw + expect(() => performVariableReplacement(extDir)).not.toThrow(); + }); + + it('should handle directories with no matching files', () => { + const extDir = path.join(testDir, 'ext'); + fs.mkdirSync(extDir, { recursive: true }); + + // Create non-matching files + fs.writeFileSync(path.join(extDir, 'file.txt'), 'content', 'utf-8'); + fs.writeFileSync(path.join(extDir, 'script.py'), 'print("hello")', 'utf-8'); + + // Should not throw + expect(() => performVariableReplacement(extDir)).not.toThrow(); + + // Files should remain unchanged + expect(fs.readFileSync(path.join(extDir, 'file.txt'), 'utf-8')).toBe( + 'content', + ); + }); +}); diff --git a/packages/core/src/extension/variables.ts b/packages/core/src/extension/variables.ts index 7bdc60d13..ba3d9a439 100644 --- a/packages/core/src/extension/variables.ts +++ b/packages/core/src/extension/variables.ts @@ -8,6 +8,11 @@ import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; import path from 'node:path'; import { QWEN_DIR } from '../config/storage.js'; import type { HookEventName, HookDefinition } from '../hooks/types.js'; +import * as fs from 'node:fs'; +import { glob } from 'glob'; +import { createDebugLogger } from '../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('Extension:variables'); // Re-export types for substituteHookVariables export type { HookEventName, HookDefinition }; @@ -111,3 +116,114 @@ export function substituteHookVariables( return clonedHooks; } + +/** + * 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)}`, + ); + } +}