diff --git a/src/installationDetection.ts b/src/installationDetection.ts index 56801bb2..f61325e1 100644 --- a/src/installationDetection.ts +++ b/src/installationDetection.ts @@ -334,6 +334,51 @@ async function getClaudeFromPath(): Promise { } } +/** + * On Windows, npm creates lightweight shim scripts (a `#!/bin/sh` shell script + * and a `.cmd` batch file) that exec the real binary. When `which` returns one + * of these shims, `resolvePathToInstallationType` sees an unrecognized text + * file instead of the actual executable. + * + * This function reads the shim content and extracts the target path. + * Returns null if the file is not a recognized npm shim. + */ +async function resolveNpmShimTarget(shimPath: string): Promise { + try { + const content = await fs.readFile(shimPath, 'utf8'); + + // npm sh shim: exec "$basedir/node_modules/.../claude.exe" "$@" + // Multiple "$basedir/..." references can appear (e.g. the "$basedir/node" + // probe at the top of npm-cli-shim sh scripts); enumerate all and pick + // the first node_modules path that exists on disk. + for (const m of content.matchAll(/"\$basedir\/([^"]+)"/g)) { + if (!m[1].includes('node_modules')) continue; + const target = path.resolve(path.dirname(shimPath), m[1]); + if (await doesFileExist(target)) { + debug(`resolveNpmShimTarget: resolved sh shim -> ${target}`); + return target; + } + } + + // npm .cmd shim: "%dp0%\node_modules\...\claude.exe" %* + for (const m of content.matchAll(/"%dp0%\\([^"]+)"/g)) { + if (!m[1].includes('node_modules')) continue; + const target = path.resolve( + path.dirname(shimPath), + m[1].replace(/\\/g, '/') + ); + if (await doesFileExist(target)) { + debug(`resolveNpmShimTarget: resolved cmd shim -> ${target}`); + return target; + } + } + + return null; + } catch { + return null; + } +} + // ============================================================================ // Version extraction // ============================================================================ @@ -682,7 +727,29 @@ export async function findClaudeCodeInstallation( if (pathClaudeExe) { debug(`Checking claude on PATH: ${pathClaudeExe}`); - const resolved = await resolvePathToInstallationType(pathClaudeExe); + let resolved = await resolvePathToInstallationType(pathClaudeExe); + + // On Windows, `which` may find an npm shim (sh/cmd wrapper) instead of the + // actual binary. Try to follow the shim to the real executable. + if (!resolved) { + const shimTarget = await resolveNpmShimTarget(pathClaudeExe); + if (shimTarget) { + debug(`Resolved npm shim -> ${shimTarget}`); + resolved = await resolvePathToInstallationType(shimTarget); + } + // Also try the .cmd variant alongside the bare shim + if (!resolved) { + const cmdPath = pathClaudeExe + '.cmd'; + if (await doesFileExist(cmdPath)) { + const cmdTarget = await resolveNpmShimTarget(cmdPath); + if (cmdTarget) { + debug(`Resolved npm .cmd shim -> ${cmdTarget}`); + resolved = await resolvePathToInstallationType(cmdTarget); + } + } + } + } + if (!resolved) { debug( `Unable to detect installation type from 'claude' on PATH (${pathClaudeExe}). ` + diff --git a/src/installationPaths.ts b/src/installationPaths.ts index 4a1a9028..5d0783e4 100644 --- a/src/installationPaths.ts +++ b/src/installationPaths.ts @@ -251,6 +251,84 @@ const getNativeSearchPathsWithInfo = (): SearchPathInfo[] => { // Versioned binaries (filenames are versions like 2.0.65) addPath(`${home}/.local/share/claude/versions/*`, true); + // CC 2.1.116+ ships as a native binary (bin/claude.exe on Windows, bin/claude elsewhere) + // inside the npm package — no cli.js. Add native binary search paths that mirror CLIJS + // search locations so the binary is discovered even when cli.js is absent. + const bin = process.platform === 'win32' ? 'bin/claude.exe' : 'bin/claude'; + const nativeMod = `node_modules/@anthropic-ai/claude-code/${bin}`; + + if (process.platform === 'win32') { + addPath( + `${home}/AppData/Local/Volta/tools/image/packages/@anthropic-ai/claude-code/${nativeMod}` + ); + addPath(`${home}/AppData/Roaming/npm/${nativeMod}`); + addPath(`${home}/AppData/Roaming/nvm/*/${nativeMod}`, true); + addPath(`${home}/AppData/Local/Yarn/config/global/${nativeMod}`); + addPath(`${home}/AppData/Local/pnpm/global/*/${nativeMod}`, true); + addPath(`${home}/AppData/Roaming/Yarn/config/global/${nativeMod}`); + addPath(`${home}/AppData/Roaming/pnpm-global/${nativeMod}`); + addPath(`${home}/AppData/Roaming/pnpm-global/*/${nativeMod}`, true); + addPath(`${home}/.bun/install/global/${nativeMod}`); + addPath(`${home}/AppData/Local/fnm_multishells/*/${nativeMod}`, true); + addPath(`${home}/AppData/Local/mise/installs/node/*/${nativeMod}`, true); + addPath( + `${home}/AppData/Local/mise/installs/npm-anthropic-ai-claude-code/*/${nativeMod}`, + true + ); + addPath(`C:/nvm4w/nodejs/${nativeMod}`); + addPath(`${home}/n/versions/node/*/lib/${nativeMod}`, true); + } else { + if (process.platform === 'darwin') { + addPath(`${home}/Library/${nativeMod}`); + addPath(`/opt/local/lib/${nativeMod}`); + } + addPath(`${home}/.local/lib/${nativeMod}`); + addPath(`${home}/.local/share/${nativeMod}`); + addPath(`${home}/.npm-global/lib/${nativeMod}`); + addPath(`${home}/.npm-packages/lib/${nativeMod}`); + addPath(`${home}/.npm/lib/${nativeMod}`); + addPath(`${home}/npm/lib/${nativeMod}`); + addPath(`/usr/lib/${nativeMod}`); + addPath(`/usr/local/lib/${nativeMod}`); + addPath(`/opt/homebrew/lib/${nativeMod}`); + addPath(`${home}/.linuxbrew/lib/${nativeMod}`); + addPath(`${home}/.config/yarn/global/${nativeMod}`); + addPath(`${home}/.yarn/global/${nativeMod}`); + addPath(`${home}/.bun/install/global/${nativeMod}`); + addPath(`${home}/.pnpm-global/${nativeMod}`); + addPath(`${home}/.pnpm-global/*/${nativeMod}`, true); + addPath(`${home}/.local/share/pnpm/global/${nativeMod}`); + addPath(`${home}/.local/share/pnpm/global/*/${nativeMod}`, true); + addPath(`${home}/.volta/tools/image/node/*/lib/${nativeMod}`, true); + addPath(`${home}/.fnm/node-versions/*/installation/lib/${nativeMod}`, true); + addPath(`${home}/.nvm/versions/node/*/lib/${nativeMod}`, true); + addPath(`/usr/local/nvm/versions/node/*/lib/${nativeMod}`, true); + addPath(`${home}/.nodenv/versions/*/lib/${nativeMod}`, true); + addPath(`${home}/.asdf/installs/nodejs/*/lib/${nativeMod}`, true); + addPath(`${home}/.local/share/mise/installs/node/*/lib/${nativeMod}`, true); + addPath( + `${home}/.local/share/mise/installs/npm-anthropic-ai-claude-code/*/lib/${nativeMod}`, + true + ); + if (process.env.NPM_PREFIX) + addPath(`${process.env.NPM_PREFIX}/lib/${nativeMod}`); + if (process.env.N_PREFIX) + addPath(`${process.env.N_PREFIX}/lib/${nativeMod}`); + if (process.env.VOLTA_HOME) + addPath(`${process.env.VOLTA_HOME}/lib/${nativeMod}`); + if (process.env.NVM_DIR) addPath(`${process.env.NVM_DIR}/lib/${nativeMod}`); + if (process.env.MISE_DATA_DIR) + addPath( + `${process.env.MISE_DATA_DIR}/installs/node/*/lib/${nativeMod}`, + true + ); + if (process.env.MISE_DATA_DIR) + addPath( + `${process.env.MISE_DATA_DIR}/installs/npm-anthropic-ai-claude-code/*/lib/${nativeMod}`, + true + ); + } + // Convert to backslashes on Windows if (process.platform === 'win32') { pathInfos.forEach(info => { diff --git a/src/patches/systemPrompts.test.ts b/src/patches/systemPrompts.test.ts index 1f41635c..087ab861 100644 --- a/src/patches/systemPrompts.test.ts +++ b/src/patches/systemPrompts.test.ts @@ -166,6 +166,60 @@ describe('systemPrompts.ts', () => { expect(result.newContent).toBe('description:"Hello\\nWorld"'); }); + it('should convert CRLF line endings to \\n for double-quoted string literals (Windows)', async () => { + const mockPromptData = buildMockPromptData({ + prompt: { content: 'Hello\r\nWorld' }, + regex: 'Hello(?:\n|\\\\n)World', + getInterpolatedContent: () => 'Hello\r\nWorld', + pieces: ['Hello\r\nWorld'], + }); + + setupMocks(mockPromptData); + + const cliContent = 'description:"Hello\\nWorld"'; + + const result = await applySystemPrompts(cliContent, '1.0.0', false); + + expect(result.newContent).toBe('description:"Hello\\nWorld"'); + expect(result.newContent).not.toMatch(/\r/); + }); + + it('should convert CRLF line endings to \\n for single-quoted string literals (Windows)', async () => { + const mockPromptData = buildMockPromptData({ + prompt: { content: 'Hello\r\nWorld' }, + regex: 'Hello(?:\n|\\\\n)World', + getInterpolatedContent: () => 'Hello\r\nWorld', + pieces: ['Hello\r\nWorld'], + }); + + setupMocks(mockPromptData); + + const cliContent = "msg:'Hello\\nWorld'"; + + const result = await applySystemPrompts(cliContent, '1.0.0', false); + + expect(result.newContent).toBe("msg:'Hello\\nWorld'"); + expect(result.newContent).not.toMatch(/\r/); + }); + + it('should normalize CRLF to LF for backtick template literals (Windows)', async () => { + const mockPromptData = buildMockPromptData({ + prompt: { content: 'Hello\r\nWorld' }, + regex: 'Hello(?:\n|\\\\n)World', + getInterpolatedContent: () => 'Hello\r\nWorld', + pieces: ['Hello\r\nWorld'], + }); + + setupMocks(mockPromptData); + + const cliContent = 'description:`Hello\nWorld`'; + + const result = await applySystemPrompts(cliContent, '1.0.0', false); + + expect(result.newContent).toBe('description:`Hello\nWorld`'); + expect(result.newContent).not.toMatch(/\r/); + }); + it('should keep actual newlines for backtick template literals', async () => { const mockPromptData = buildMockPromptData({ prompt: { content: 'Hello\nWorld' }, diff --git a/src/patches/systemPrompts.ts b/src/patches/systemPrompts.ts index d43f4c44..ea6cee21 100644 --- a/src/patches/systemPrompts.ts +++ b/src/patches/systemPrompts.ts @@ -161,12 +161,15 @@ export const applySystemPrompts = async ( } if (delimiter === '"') { - replacementContent = replacementContent.replace(/\n/g, '\\n'); + replacementContent = replacementContent.replace(/\r\n|\r|\n/g, '\\n'); replacementContent = escapeUnescapedChar(replacementContent, '"'); } else if (delimiter === "'") { - replacementContent = replacementContent.replace(/\n/g, '\\n'); + replacementContent = replacementContent.replace(/\r\n|\r|\n/g, '\\n'); replacementContent = escapeUnescapedChar(replacementContent, "'"); } else if (delimiter === '`') { + replacementContent = replacementContent + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); const { content: escaped, incomplete } = escapeDepthZeroBackticks(replacementContent); if (incomplete) {