From f1960fad679aeb875cee40c39c75884f185c3357 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 11 Apr 2026 11:19:47 -0400 Subject: [PATCH] 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 --- bin/install.js | 16 +++ tests/bug-1834-sh-hooks-installed.test.cjs | 4 +- tests/qwen-install.test.cjs | 138 +++++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/bin/install.js b/bin/install.js index 28d59011d..5650ceb9a 100755 --- a/bin/install.js +++ b/bin/install.js @@ -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) diff --git a/tests/bug-1834-sh-hooks-installed.test.cjs b/tests/bug-1834-sh-hooks-installed.test.cjs index bb1f7a3e9..1fa19fefc 100644 --- a/tests/bug-1834-sh-hooks-installed.test.cjs +++ b/tests/bug-1834-sh-hooks-installed.test.cjs @@ -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')" diff --git a/tests/qwen-install.test.cjs b/tests/qwen-install.test.cjs index 0af4f4da0..721288058 100644 --- a/tests/qwen-install.test.cjs +++ b/tests/qwen-install.test.cjs @@ -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')); + }); +});