diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 187b984602..f499c27134 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -11,6 +11,7 @@ import { getShellConfiguration, ShellExecutionService, flatMapTextParts, + checkArgumentSafety, } from '@qwen-code/qwen-code-core'; import type { CommandContext } from '../../ui/commands/types.js'; @@ -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; @@ -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 }; }, ); diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index c984b17bc8..5a251ce266 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', () => { @@ -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'); }); }); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index ff5ba72a90..9ef2878272 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.test.ts b/packages/core/src/extension/extensionManager.test.ts index 8ef27da30f..10a39dc5eb 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -891,5 +891,169 @@ describe('extension tests', () => { `${extensionDir}/scripts/setup.sh`, ); }); + + it('should load hooks from config.hooks string path', async () => { + const extensionDir = path.join( + userExtensionsDir, + 'hooks-from-config-path', + ); + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create custom hooks directory and hooks file + const customHooksDir = path.join(extensionDir, 'custom-hooks'); + fs.mkdirSync(customHooksDir, { recursive: true }); + + const hooksJson = { + PreToolUse: [ + { + description: 'Run from custom path', + hooks: [ + { + type: 'command', + command: 'echo "custom hooks path"', + }, + ], + }, + ], + }; + + fs.writeFileSync( + path.join(customHooksDir, 'hooks.json'), + JSON.stringify(hooksJson), + ); + + // Create qwen-extension.json with hooks as string path + const configWithHooksPath = { + name: 'hooks-from-config-path', + version: '1.0.0', + hooks: 'custom-hooks/hooks.json', + }; + + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(configWithHooksPath), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeDefined(); + expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); + expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( + 'echo "custom hooks path"', + ); + }); + + it('should prefer config.hooks string path over hooks/hooks.json', async () => { + const extensionDir = path.join( + userExtensionsDir, + 'hooks-prefer-config-path', + ); + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create hooks/hooks.json + const hooksDir = path.join(extensionDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify({ + PreToolUse: [ + { + description: 'From hooks directory', + hooks: [{ type: 'command', command: 'echo "hooks dir"' }], + }, + ], + }), + ); + + // Create custom hooks file + const customHooksDir = path.join(extensionDir, 'custom'); + fs.mkdirSync(customHooksDir, { recursive: true }); + fs.writeFileSync( + path.join(customHooksDir, 'my-hooks.json'), + JSON.stringify({ + PreToolUse: [ + { + description: 'From config path', + hooks: [{ type: 'command', command: 'echo "config path"' }], + }, + ], + }), + ); + + // Create qwen-extension.json with hooks as string path + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify({ + name: 'hooks-prefer-config-path', + version: '1.0.0', + hooks: 'custom/my-hooks.json', + }), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeDefined(); + expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( + 'echo "config path"', + ); + }); + + it('should substitute ${CLAUDE_PLUGIN_ROOT} in hooks file from config.hooks string path', async () => { + const extensionDir = path.join( + userExtensionsDir, + 'hooks-var-from-config-path', + ); + fs.mkdirSync(extensionDir, { recursive: true }); + + const customHooksDir = path.join(extensionDir, 'my-hooks'); + fs.mkdirSync(customHooksDir, { recursive: true }); + + const hooksJson = { + PreToolUse: [ + { + description: 'Run with variable', + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh', + }, + ], + }, + ], + }; + + fs.writeFileSync( + path.join(customHooksDir, 'hooks.json'), + JSON.stringify(hooksJson), + ); + + const configWithHooksPath = { + name: 'hooks-var-from-config-path', + version: '1.0.0', + hooks: 'my-hooks/hooks.json', + }; + + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(configWithHooksPath), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeDefined(); + expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); + expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( + `${extensionDir}/scripts/setup.sh`, + ); + }); }); }); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index d0382347e0..15ead552dd 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 { @@ -666,7 +667,7 @@ export class ExtensionManager { `${effectiveExtensionPath}/agents`, ); - if (config.hooks) { + if (config.hooks && typeof config.hooks !== 'string') { // Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT} extension.hooks = this.substituteHookVariables( config.hooks, @@ -674,17 +675,31 @@ export class ExtensionManager { ); } - // Also load hooks from hooks directory if available and not already set + // Also load hooks from hooks directory or from config.hooks string path if available and not already set if (!extension.hooks) { const hooksDir = path.join(effectiveExtensionPath, 'hooks'); const hooksJsonPath = path.join(hooksDir, 'hooks.json'); - if (fs.existsSync(hooksJsonPath)) { + const configHooksPath = + typeof config.hooks === 'string' + ? path.isAbsolute(config.hooks) + ? config.hooks + : path.join(effectiveExtensionPath, config.hooks) + : null; + + if ( + fs.existsSync(hooksJsonPath) || + (configHooksPath && fs.existsSync(configHooksPath)) + ) { + const hooksFilePath = + configHooksPath && fs.existsSync(configHooksPath) + ? configHooksPath + : hooksJsonPath; + try { - const hooksContent = fs.readFileSync(hooksJsonPath, 'utf-8'); + const hooksContent = fs.readFileSync(hooksFilePath, 'utf-8'); const parsedHooks = JSON.parse(hooksContent); - // Check if the file has a top-level "hooks" property or if the entire file content is the hooks object let hooksData; if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') { hooksData = parsedHooks.hooks as { @@ -987,6 +1002,28 @@ export class ExtensionManager { await copyExtension(localSourcePath, destinationPath); } + // Perform variable replacement in extension files (e.g., ${CLAUDE_PLUGIN_ROOT}) for Claude extensions + const hooksDir = path.join(destinationPath, 'hooks'); + const configHooksPath = + typeof newExtensionConfig.hooks === 'string' + ? path.isAbsolute(newExtensionConfig.hooks) + ? newExtensionConfig.hooks + : path.join(destinationPath, newExtensionConfig.hooks) + : null; + + if ( + (originSource === 'Claude' && fs.existsSync(hooksDir)) || + (originSource === 'Claude' && + configHooksPath && + fs.existsSync(configHooksPath)) + ) { + try { + await performVariableReplacement(destinationPath); + } catch (error) { + debugLogger.error('Variable replacement failed', error); + } + } + 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 e8a1db7143..685a70064f 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,258 @@ 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'); + 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('!{'); + expect(result).toContain('npm install'); + expect(result).toContain('npm run build'); + 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'); + 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/.claude'); + expect(result).toContain('~/.qwen/cache'); + expect(result).toContain('./.qwen/local'); + expect(result).toContain('.qwen/config'); + // Note: URLs are also being replaced in current implementation + expect(result).toContain('https://example.com/.qwen/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('${CLAUDE_PLUGIN_ROOT}/setup'); + expect(helper).toContain('${CLAUDE_PLUGIN_ROOT}/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', + ); + }); + + describe('regex boundary cases', () => { + it('should not replace incomplete variable syntax (missing brace) in markdown', () => { + const extDir = path.join(testDir, 'ext-incomplete'); + fs.mkdirSync(extDir, { recursive: true }); + + // Note: performVariableReplacement only processes .md files + const content = 'Path: $CLAUDE_PLUGIN_ROOT/config.json'; + fs.writeFileSync(path.join(extDir, 'test.md'), content, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'test.md'), 'utf-8'); + // Should remain unchanged (no braces) + expect(result).toBe('Path: $CLAUDE_PLUGIN_ROOT/config.json'); + }); + + it('should replace double dollar sign but keep first dollar', () => { + const extDir = path.join(testDir, 'ext-double-dollar'); + fs.mkdirSync(extDir, { recursive: true }); + + // Note: performVariableReplacement only processes .md files + // The regex matches ${CLAUDE_PLUGIN_ROOT}, leaving first $ intact + const content = 'Path: $${CLAUDE_PLUGIN_ROOT}/config.json'; + fs.writeFileSync(path.join(extDir, 'test.md'), content, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'test.md'), 'utf-8'); + // First $ is preserved, variable is replaced + expect(result).toBe(`Path: $${extDir}/config.json`); + }); + + it('should replace variable in markdown comments', () => { + const extDir = path.join(testDir, 'ext-comment'); + fs.mkdirSync(extDir, { recursive: true }); + + // Comments in markdown files should be processed + const content = '# TODO: Update ${CLAUDE_PLUGIN_ROOT} later'; + fs.writeFileSync(path.join(extDir, 'test.md'), content, 'utf-8'); + + performVariableReplacement(extDir); + + const result = fs.readFileSync(path.join(extDir, 'test.md'), 'utf-8'); + // Should be replaced (comments in markdown are still processed) + expect(result).toContain(extDir); + expect(result).not.toContain('${CLAUDE_PLUGIN_ROOT}'); + }); + }); +}); diff --git a/packages/core/src/extension/variables.ts b/packages/core/src/extension/variables.ts index 7bdc60d13b..ba3d9a4398 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)}`, + ); + } +} diff --git a/packages/core/src/utils/shell-utils.test.ts b/packages/core/src/utils/shell-utils.test.ts index 7a02ba4a7e..bb77012b2a 100644 --- a/packages/core/src/utils/shell-utils.test.ts +++ b/packages/core/src/utils/shell-utils.test.ts @@ -6,6 +6,7 @@ import { expect, describe, it, beforeEach, vi, afterEach } from 'vitest'; import { + checkArgumentSafety, checkCommandPermissions, escapeShellArg, getCommandRoots, @@ -607,3 +608,136 @@ describe('isCommandNeedPermission', () => { expect(result.reason).toContain('requires permission to execute'); }); }); + +describe('checkArgumentSafety', () => { + describe('command substitution patterns', () => { + it('should detect $() command substitution', () => { + const result = checkArgumentSafety('$(whoami)'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('$() command substitution'); + }); + + it('should detect backtick command substitution', () => { + const result = checkArgumentSafety('`whoami`'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain( + 'backtick command substitution', + ); + }); + + it('should detect <() process substitution', () => { + const result = checkArgumentSafety('<(cat file)'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('<() process substitution'); + }); + + it('should detect >() process substitution', () => { + const result = checkArgumentSafety('>(tee file)'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('>() process substitution'); + }); + }); + + describe('command separators', () => { + it('should detect semicolon separator', () => { + const result = checkArgumentSafety('arg1; rm -rf /'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('; command separator'); + }); + + it('should detect pipe', () => { + const result = checkArgumentSafety('arg1 | cat file'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('| pipe'); + }); + + it('should detect && operator', () => { + const result = checkArgumentSafety('arg1 && ls'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('&& AND operator'); + }); + + it('should detect || operator', () => { + const result = checkArgumentSafety('arg1 || ls'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('|| OR operator'); + }); + }); + + describe('background execution', () => { + it('should detect background operator', () => { + const result = checkArgumentSafety('arg1 & ls'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('& background operator'); + }); + }); + + describe('input/output redirection', () => { + it('should detect output redirection', () => { + const result = checkArgumentSafety('arg1 > file'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('> output redirection'); + }); + + it('should detect input redirection', () => { + const result = checkArgumentSafety('arg1 < file'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('< input redirection'); + }); + + it('should detect append redirection', () => { + const result = checkArgumentSafety('arg1 >> file'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('> output redirection'); + }); + }); + + describe('safe inputs', () => { + it('should accept simple arguments', () => { + const result = checkArgumentSafety('arg1 arg2'); + expect(result.isSafe).toBe(true); + expect(result.dangerousPatterns).toHaveLength(0); + }); + + it('should accept arguments with numbers', () => { + const result = checkArgumentSafety('file123.txt'); + expect(result.isSafe).toBe(true); + }); + + it('should accept arguments with hyphens', () => { + const result = checkArgumentSafety('--flag=value'); + expect(result.isSafe).toBe(true); + }); + + it('should accept arguments with underscores', () => { + const result = checkArgumentSafety('my_file_name'); + expect(result.isSafe).toBe(true); + }); + + it('should accept arguments with dots', () => { + const result = checkArgumentSafety('path/to/file.txt'); + expect(result.isSafe).toBe(true); + }); + + it('should accept empty string', () => { + const result = checkArgumentSafety(''); + expect(result.isSafe).toBe(true); + }); + + it('should accept arguments with spaces (quoted)', () => { + const result = checkArgumentSafety('hello world'); + expect(result.isSafe).toBe(true); + }); + }); + + describe('multiple dangerous patterns', () => { + it('should detect multiple dangerous patterns', () => { + const result = checkArgumentSafety('$(whoami); rm -rf / &'); + expect(result.isSafe).toBe(false); + expect(result.dangerousPatterns).toContain('$() command substitution'); + expect(result.dangerousPatterns).toContain('; command separator'); + expect(result.dangerousPatterns).toContain('& background operator'); + expect(result.dangerousPatterns).toHaveLength(3); + }); + }); +}); diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index f0cd2bb130..7036936e24 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -974,6 +974,52 @@ export function isCommandNeedsPermission(command: string): { }; } +/** + * Checks user arguments for potentially dangerous shell characters. + * This is used to validate arguments before they are substituted into + * shell command templates (e.g., $ARGUMENTS placeholder). + * + * Note: This does NOT remove outer quotes - it validates the raw input. + * Use escapeShellArg() for safe shell argument escaping. + * + * @param args - The raw user arguments string + * @returns Object with isSafe flag and list of dangerous patterns found + */ +export function checkArgumentSafety(args: string): { + isSafe: boolean; + dangerousPatterns: string[]; +} { + const dangerousPatterns: string[] = []; + + // Command substitution patterns + if (args.includes('$(')) dangerousPatterns.push('$() command substitution'); + if (args.includes('`')) + dangerousPatterns.push('backtick command substitution'); + if (args.includes('<(')) dangerousPatterns.push('<() process substitution'); + if (args.includes('>(')) dangerousPatterns.push('>() process substitution'); + + // Command separators (outside of quotes) + if (args.includes(';')) dangerousPatterns.push('; command separator'); + if (args.includes('|')) dangerousPatterns.push('| pipe'); + if (args.includes('&&')) dangerousPatterns.push('&& AND operator'); + if (args.includes('||')) dangerousPatterns.push('|| OR operator'); + + // Background execution (space + &, with optional surrounding) + if (args.includes(' &') || args.includes('& ')) + dangerousPatterns.push('& background operator'); + + // Input/Output redirection + if (args.includes('>') || args.includes('<')) { + if (/>\s|\d>/.test(args)) dangerousPatterns.push('> output redirection'); + if (/<\s|\d