Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/installationDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,51 @@ async function getClaudeFromPath(): Promise<string | null> {
}
}

/**
* 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<string | null> {
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
// ============================================================================
Expand Down Expand Up @@ -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}). ` +
Expand Down
78 changes: 78 additions & 0 deletions src/installationPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
54 changes: 54 additions & 0 deletions src/patches/systemPrompts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
7 changes: 5 additions & 2 deletions src/patches/systemPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down