From 349daf7e6a12a2e00af5d044c9cf8bd9836a4c5d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 22 Apr 2026 12:04:17 -0400 Subject: [PATCH] fix(#2545): use word boundary in path replacement to catch ~/.claude without trailing slash (#2592) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Copilot content converter only replaced `~/.claude/` and `$HOME/.claude/` when followed by a literal `/`. Bare references (e.g. `configDir = ~/.claude` at end of line) slipped through and triggered the post-install "Found N unreplaced .claude path reference(s)" warning, since the leak scanner uses `(?:~|$HOME)/\.claude\b`. Switched both replacements to a `(\/|\b)` capture group so trailing-slash and bare forms are handled in a single pass — matching the pattern already used by Antigravity, OpenCode, Kilo, and Codex converters. Closes #2545 --- bin/install.js | 12 ++-- ...bug-2545-copilot-unreplaced-paths.test.cjs | 64 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/bug-2545-copilot-unreplaced-paths.test.cjs diff --git a/bin/install.js b/bin/install.js index 275fe93b..1cc51b66 100755 --- a/bin/install.js +++ b/bin/install.js @@ -877,14 +877,18 @@ function convertCopilotToolName(claudeTool) { */ function convertClaudeToCopilotContent(content, isGlobal = false) { let c = content; - // CONV-06: Path replacement — most specific first to avoid substring matches + // CONV-06: Path replacement — most specific first to avoid substring matches. + // Handle both `~/.claude/foo` (trailing slash) and bare `~/.claude` forms in + // one pass via a capture group, matching the approach used by Antigravity, + // OpenCode, Kilo, and Codex converters (issue #2545). if (isGlobal) { - c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.copilot/'); - c = c.replace(/~\/\.claude\//g, '~/.copilot/'); + c = c.replace(/\$HOME\/\.claude(\/|\b)/g, '$HOME/.copilot$1'); + c = c.replace(/~\/\.claude(\/|\b)/g, '~/.copilot$1'); } else { c = c.replace(/\$HOME\/\.claude\//g, '.github/'); c = c.replace(/~\/\.claude\//g, '.github/'); - c = c.replace(/~\/\.claude\n/g, '.github/'); + c = c.replace(/\$HOME\/\.claude\b/g, '.github'); + c = c.replace(/~\/\.claude\b/g, '.github'); } c = c.replace(/\.\/\.claude\//g, './.github/'); c = c.replace(/\.claude\//g, '.github/'); diff --git a/tests/bug-2545-copilot-unreplaced-paths.test.cjs b/tests/bug-2545-copilot-unreplaced-paths.test.cjs new file mode 100644 index 00000000..2fa00a22 --- /dev/null +++ b/tests/bug-2545-copilot-unreplaced-paths.test.cjs @@ -0,0 +1,64 @@ +/** + * Regression test for issue #2545. + * + * The Copilot content converter's `~/.claude/` and `$HOME/.claude/` replacements + * only matched when a literal slash followed, so bare `~/.claude` references + * (end of line, quotes, punctuation) were left unreplaced. Those leaks then + * triggered the installer's "Found N unreplaced .claude path reference(s)" + * warning, which scans for `(?:~|$HOME)/\.claude\b`. + * + * Fix: replace with a word-boundary pattern so both forms are caught in a + * single pass, matching the approach already used by the Antigravity, OpenCode, + * Kilo, and Codex converters. + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { convertClaudeToCopilotContent } = require('../bin/install.js'); + +describe('convertClaudeToCopilotContent — bare ~/.claude (issue #2545)', () => { + test('global install replaces bare ~/.claude at end of line', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /~\/\.copilot\b/); + }); + + test('global install replaces bare $HOME/.claude at end of line', () => { + const input = 'configDir = $HOME/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ true); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked $HOME/.claude reference, got: ${JSON.stringify(out)}`, + ); + assert.match(out, /\$HOME\/\.copilot\b/); + }); + + test('global install replaces bare ~/.claude before punctuation', () => { + const input = 'paths include `~/.claude`, `~/.copilot`'; + const out = convertClaudeToCopilotContent(input, true); + assert.ok(!/(?:~|\$HOME)\/\.claude\b/.test(out)); + }); + + test('local install replaces bare ~/.claude', () => { + const input = 'configDir = ~/.claude\n'; + const out = convertClaudeToCopilotContent(input, /* isGlobal */ false); + assert.ok( + !/(?:~|\$HOME)\/\.claude\b/.test(out), + `expected no leaked ~/.claude reference, got: ${JSON.stringify(out)}`, + ); + }); + + test('does not double-replace trailing-slash form', () => { + const input = '@~/.claude/get-shit-done/foo.md\n'; + const out = convertClaudeToCopilotContent(input, true); + assert.match(out, /~\/\.copilot\/get-shit-done\/foo\.md/); + assert.ok(!/\.copilot\/\.copilot/.test(out)); + }); +});