fix(install): eliminate Claude reference leaks in Qwen install paths (#2112)
Three install code paths were leaking Claude-specific references into Qwen installs: copyCommandsAsClaudeSkills lacked runtime-aware content replacement, the agents copy loop had no isQwen branch, and the hooks template loop only replaced the quoted '.claude' form. Added CLAUDE.md, Claude Code, and .claude/ replacements across all three paths plus copyWithPathReplacement's Qwen .md branch. Includes regression test that walks the full .qwen/ tree after install and asserts zero Claude references outside CHANGELOG.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3956,6 +3956,12 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti
|
||||
content = content.replace(/~\/\.qwen\//g, pathPrefix);
|
||||
content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix);
|
||||
content = content.replace(/\.\/\.qwen\//g, `./${getDirName(runtime)}/`);
|
||||
// Qwen reuses Claude skill format but needs runtime-specific content replacement
|
||||
if (runtime === 'qwen') {
|
||||
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
|
||||
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
|
||||
content = content.replace(/\.claude\//g, '.qwen/');
|
||||
}
|
||||
content = processAttribution(content, getCommitAttribution(runtime));
|
||||
content = convertClaudeCommandToClaudeSkill(content, skillName);
|
||||
|
||||
@@ -4152,6 +4158,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand
|
||||
} else if (isQwen) {
|
||||
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
|
||||
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
|
||||
content = content.replace(/\.claude\//g, '.qwen/');
|
||||
fs.writeFileSync(destPath, content);
|
||||
} else {
|
||||
fs.writeFileSync(destPath, content);
|
||||
@@ -5682,6 +5689,10 @@ function install(isGlobal, runtime = 'claude') {
|
||||
content = convertClaudeAgentToCodebuddyAgent(content);
|
||||
} else if (isCline) {
|
||||
content = convertClaudeAgentToClineAgent(content);
|
||||
} else if (isQwen) {
|
||||
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
|
||||
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
|
||||
content = content.replace(/\.claude\//g, '.qwen/');
|
||||
}
|
||||
const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name;
|
||||
fs.writeFileSync(path.join(agentsDest, destName), content);
|
||||
@@ -5740,6 +5751,11 @@ function install(isGlobal, runtime = 'claude') {
|
||||
if (entry.endsWith('.js')) {
|
||||
let content = fs.readFileSync(srcFile, 'utf8');
|
||||
content = content.replace(/'\.claude'/g, configDirReplacement);
|
||||
content = content.replace(/\/\.claude\//g, `/${getDirName(runtime)}/`);
|
||||
if (isQwen) {
|
||||
content = content.replace(/CLAUDE\.md/g, 'QWEN.md');
|
||||
content = content.replace(/\bClaude Code\b/g, 'Qwen Code');
|
||||
}
|
||||
content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version);
|
||||
fs.writeFileSync(destFile, content);
|
||||
// Ensure hook files are executable (fixes #1162 — missing +x permission)
|
||||
|
||||
@@ -157,8 +157,8 @@ describe('#1834: install.js source handles .sh files in the hook copy loop', ()
|
||||
const anchorPhrase = 'configDirReplacement';
|
||||
const anchorIdx = src.indexOf(anchorPhrase);
|
||||
assert.ok(anchorIdx !== -1, 'hook copy loop anchor (configDirReplacement) not found in install.js');
|
||||
// Extract a window large enough to contain the if/else block (≈1000 chars)
|
||||
const region = src.slice(anchorIdx, anchorIdx + 1000);
|
||||
// Extract a window large enough to contain the if/else block (≈1500 chars)
|
||||
const region = src.slice(anchorIdx, anchorIdx + 1500);
|
||||
assert.ok(
|
||||
region.includes("entry.endsWith('.js')"),
|
||||
"install.js hook copy loop must check entry.endsWith('.js')"
|
||||
|
||||
@@ -176,3 +176,141 @@ describe('E2E: Qwen Code uninstall skills cleanup', () => {
|
||||
'get-shit-done engine should be removed after Qwen uninstall');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Regression: no Claude references leak into Qwen install (#2112) ──────────
|
||||
|
||||
describe('Qwen install contains no leaked Claude references (#2112)', () => {
|
||||
let tmpDir;
|
||||
let previousCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-qwen-refs-');
|
||||
previousCwd = process.cwd();
|
||||
process.chdir(tmpDir);
|
||||
install(false, 'qwen');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(previousCwd);
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively walk a directory and return all file paths.
|
||||
*/
|
||||
function walk(dir) {
|
||||
const results = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...walk(full));
|
||||
} else {
|
||||
results.push(full);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return files under .qwen/ that contain Claude references,
|
||||
* excluding CHANGELOG.md (historical accuracy) and VERSION (no prose).
|
||||
*/
|
||||
function findClaudeLeaks() {
|
||||
const qwenDir = path.join(tmpDir, '.qwen');
|
||||
const allFiles = walk(qwenDir);
|
||||
const textFiles = allFiles.filter(f =>
|
||||
f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js')
|
||||
);
|
||||
const excluded = ['CHANGELOG.md'];
|
||||
const candidates = textFiles.filter(f =>
|
||||
!excluded.includes(path.basename(f))
|
||||
);
|
||||
const leaks = [];
|
||||
for (const file of candidates) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\bCLAUDE\.md\b/.test(content) ||
|
||||
/\bClaude Code\b/.test(content) ||
|
||||
/\.claude\//.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
return leaks;
|
||||
}
|
||||
|
||||
test('skills contain no CLAUDE.md or Claude Code references', () => {
|
||||
const qwenDir = path.join(tmpDir, '.qwen');
|
||||
const skillsDir = path.join(qwenDir, 'skills');
|
||||
assert.ok(fs.existsSync(skillsDir), 'skills directory exists');
|
||||
|
||||
const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(skillFiles.length > 0, 'at least one skill file exists');
|
||||
|
||||
const leaks = [];
|
||||
for (const file of skillFiles) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'Skills should not contain Claude references after Qwen install.',
|
||||
'Leaking files:',
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('agents contain no CLAUDE.md or Claude Code references', () => {
|
||||
const agentsDir = path.join(tmpDir, '.qwen', 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), 'agents directory exists');
|
||||
|
||||
const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md'));
|
||||
assert.ok(agentFiles.length > 0, 'at least one agent file exists');
|
||||
|
||||
const leaks = [];
|
||||
for (const file of agentFiles) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'Agents should not contain Claude references after Qwen install.',
|
||||
'Leaking files:',
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('hooks contain no .claude/ path references', () => {
|
||||
const hooksDir = path.join(tmpDir, '.qwen', 'hooks');
|
||||
if (!fs.existsSync(hooksDir)) {
|
||||
return; // hooks may not be present in local installs
|
||||
}
|
||||
|
||||
const hookFiles = walk(hooksDir).filter(f => f.endsWith('.js'));
|
||||
const leaks = [];
|
||||
for (const file of hookFiles) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
if (/\.claude\//.test(content)) {
|
||||
leaks.push(path.relative(tmpDir, file));
|
||||
}
|
||||
}
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'Hooks should not contain .claude/ path references after Qwen install.',
|
||||
'Leaking files:',
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
|
||||
test('full tree scan finds zero Claude references outside CHANGELOG.md', () => {
|
||||
const leaks = findClaudeLeaks();
|
||||
assert.strictEqual(leaks.length, 0,
|
||||
[
|
||||
'No files under .qwen/ (except CHANGELOG.md) should contain Claude references.',
|
||||
`Found ${leaks.length} leaking file(s):`,
|
||||
...leaks,
|
||||
].join('\n'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user