mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
improve to change extension path instead of tmp path and fix shell multiple input
This commit is contained in:
parent
257934f1e9
commit
b5186d3c8a
6 changed files with 427 additions and 119 deletions
|
|
@ -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 };
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue