improve to change extension path instead of tmp path and fix shell multiple input

This commit is contained in:
DennisYu07 2026-03-19 12:02:54 +08:00
parent 257934f1e9
commit b5186d3c8a
6 changed files with 427 additions and 119 deletions

View file

@ -111,7 +111,13 @@ export class ShellProcessor implements IPromptProcessor {
const resolvedCommand = command const resolvedCommand = command
.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}} .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 }; return { ...injection, resolvedCommand };
}, },
); );

View file

@ -18,6 +18,7 @@ import {
type ClaudeMarketplaceConfig, type ClaudeMarketplaceConfig,
} from './claude-converter.js'; } from './claude-converter.js';
import { HookType } from '../hooks/types.js'; import { HookType } from '../hooks/types.js';
import { performVariableReplacement } from './variables.js';
describe('convertClaudeToQwenConfig', () => { describe('convertClaudeToQwenConfig', () => {
it('should convert basic Claude config', () => { it('should convert basic Claude config', () => {
@ -571,3 +572,81 @@ describe('convertClaudePluginPackage', () => {
fs.rmSync(result.convertedDir, { recursive: true, force: true }); 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');
});
});

View file

@ -30,117 +30,6 @@ import { substituteHookVariables } from './variables.js';
const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); 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 { export interface ClaudePluginConfig {
name: string; name: string;
version: 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'); const agentsDestDir = path.join(tmpDir, 'agents');
await convertAgentFiles(agentsDestDir); 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 // Step 10: Convert to Qwen format config
const qwenConfig = convertClaudeToQwenConfig(mergedConfig); const qwenConfig = convertClaudeToQwenConfig(mergedConfig);

View file

@ -30,6 +30,7 @@ import {
INSTALL_METADATA_FILENAME, INSTALL_METADATA_FILENAME,
recursivelyHydrateStrings, recursivelyHydrateStrings,
substituteHookVariables, substituteHookVariables,
performVariableReplacement,
} from './variables.js'; } from './variables.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
import { import {
@ -987,6 +988,11 @@ export class ExtensionManager {
await copyExtension(localSourcePath, destinationPath); 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 metadataString = JSON.stringify(installMetadata, null, 2);
const metadataPath = path.join( const metadataPath = path.join(
destinationPath, destinationPath,

View file

@ -4,9 +4,16 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { expect, describe, it } from 'vitest'; import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { hydrateString, substituteHookVariables } from './variables.js'; import {
hydrateString,
substituteHookVariables,
performVariableReplacement,
} from './variables.js';
import { HookType } from '../hooks/types.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', () => { describe('hydrateString', () => {
it('should replace a single variable', () => { it('should replace a single variable', () => {
@ -194,3 +201,212 @@ describe('substituteHookVariables', () => {
expect(result!['Stop']![0].hooks![0].command).toBe('echo "hello world"'); 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',
);
});
});

View file

@ -8,6 +8,11 @@ import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
import path from 'node:path'; import path from 'node:path';
import { QWEN_DIR } from '../config/storage.js'; import { QWEN_DIR } from '../config/storage.js';
import type { HookEventName, HookDefinition } from '../hooks/types.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 // Re-export types for substituteHookVariables
export type { HookEventName, HookDefinition }; export type { HookEventName, HookDefinition };
@ -111,3 +116,114 @@ export function substituteHookVariables(
return clonedHooks; 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)}`,
);
}
}