mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 08:41:16 +00:00
* fix(anthropic): prefer native Claude catalog terminals * chore: keep release notes in PR body
154 lines
4.9 KiB
TypeScript
154 lines
4.9 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { resolveNodeHostExecutable } from "openclaw/plugin-sdk/node-host";
|
|
|
|
const BROKEN_NPM_SHIM_MARKER = "Error: claude native binary not installed.";
|
|
const BROKEN_NPM_INSTALL_HINT = "node_modules/@anthropic-ai/claude-code/install.cjs";
|
|
const CLAUDE_PACKAGE_SHIM_TARGET =
|
|
/(?:\$basedir|%dp0%)\/([^"'\r\n]*?node_modules\/@anthropic-ai\/claude-code\/bin\/claude\.exe)/giu;
|
|
const MAX_SHIM_PROBE_BYTES = 4096;
|
|
|
|
let cachedNativeReplacement: { key: string; executable: string | null } | undefined;
|
|
|
|
function currentHomeDir(env: NodeJS.ProcessEnv): string {
|
|
return env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
|
}
|
|
|
|
function readSmallExecutableSource(executable: string):
|
|
| {
|
|
realPath: string;
|
|
source: string;
|
|
}
|
|
| undefined {
|
|
try {
|
|
const realPath = fs.realpathSync(executable);
|
|
if (fs.statSync(realPath).size > MAX_SHIM_PROBE_BYTES) {
|
|
return undefined;
|
|
}
|
|
return { realPath, source: fs.readFileSync(realPath, "utf8") };
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function isFailedClaudeNpmPlaceholder(source: string): boolean {
|
|
return source.includes(BROKEN_NPM_SHIM_MARKER) && source.includes(BROKEN_NPM_INSTALL_HINT);
|
|
}
|
|
|
|
function isBrokenClaudeNpmShim(executable: string): boolean {
|
|
const shim = readSmallExecutableSource(executable);
|
|
if (!shim) {
|
|
return false;
|
|
}
|
|
if (isFailedClaudeNpmPlaceholder(shim.source)) {
|
|
return true;
|
|
}
|
|
// npm's cross-platform command shims call the package binary through
|
|
// $basedir or %dp0%; inspect that bounded target without executing the shim.
|
|
const normalizedSource = shim.source.replaceAll("\\", "/");
|
|
const baseDirectories = new Set([path.dirname(executable), path.dirname(shim.realPath)]);
|
|
for (const match of normalizedSource.matchAll(CLAUDE_PACKAGE_SHIM_TARGET)) {
|
|
const relativeTarget = match[1];
|
|
if (!relativeTarget) {
|
|
continue;
|
|
}
|
|
for (const baseDirectory of baseDirectories) {
|
|
const target = readSmallExecutableSource(path.resolve(baseDirectory, relativeTarget));
|
|
if (target && isFailedClaudeNpmPlaceholder(target.source)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function resolveExecutableFromDirectory(
|
|
directory: string,
|
|
env: NodeJS.ProcessEnv,
|
|
): string | undefined {
|
|
const resolution = resolveNodeHostExecutable("claude", {
|
|
env,
|
|
pathEnv: directory,
|
|
strategy: "direct",
|
|
});
|
|
return resolution && !isBrokenClaudeNpmShim(resolution.executable)
|
|
? resolution.executable
|
|
: undefined;
|
|
}
|
|
|
|
function resolveClaudeDesktopExecutable(
|
|
homeDir: string,
|
|
env: NodeJS.ProcessEnv,
|
|
): string | undefined {
|
|
if (process.platform !== "darwin") {
|
|
return undefined;
|
|
}
|
|
const versionsRoot = path.join(
|
|
homeDir,
|
|
"Library",
|
|
"Application Support",
|
|
"Claude",
|
|
"claude-code",
|
|
);
|
|
let versions: fs.Dirent[];
|
|
try {
|
|
versions = fs.readdirSync(versionsRoot, { withFileTypes: true });
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
versions.sort((left, right) =>
|
|
right.name.localeCompare(left.name, undefined, { numeric: true, sensitivity: "base" }),
|
|
);
|
|
for (const version of versions) {
|
|
if (!version.isDirectory()) {
|
|
continue;
|
|
}
|
|
const executable = resolveExecutableFromDirectory(
|
|
path.join(versionsRoot, version.name, "claude.app", "Contents", "MacOS"),
|
|
env,
|
|
);
|
|
if (executable) {
|
|
return executable;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveNativeReplacement(env: NodeJS.ProcessEnv): string | undefined {
|
|
const homeDir = currentHomeDir(env);
|
|
const cacheKey = `${process.platform}\0${homeDir}`;
|
|
if (cachedNativeReplacement?.key === cacheKey) {
|
|
return cachedNativeReplacement.executable ?? undefined;
|
|
}
|
|
// Claude installations are process-stable metadata. Cache the lookup so
|
|
// catalog requests do not poll the filesystem; installs take effect on restart.
|
|
const executable =
|
|
resolveExecutableFromDirectory(path.join(homeDir, ".local", "bin"), env) ??
|
|
resolveClaudeDesktopExecutable(homeDir, env);
|
|
cachedNativeReplacement = { key: cacheKey, executable: executable ?? null };
|
|
return executable;
|
|
}
|
|
|
|
// Anthropic's failed npm postinstall leaves an executable placeholder that can
|
|
// never launch. Keep normal shell wrappers, but replace that known failure with
|
|
// a native installer or Claude Desktop binary and retain the user's shell PATH.
|
|
export function resolveClaudeTerminalExecutable(env: NodeJS.ProcessEnv = process.env) {
|
|
const shellResolution = resolveNodeHostExecutable("claude", {
|
|
env,
|
|
pathEnv: env.PATH ?? env.Path ?? "",
|
|
strategy: "prefer",
|
|
});
|
|
if (shellResolution && !isBrokenClaudeNpmShim(shellResolution.executable)) {
|
|
return shellResolution;
|
|
}
|
|
const nativeExecutable = resolveNativeReplacement(env);
|
|
if (!nativeExecutable) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
executable: nativeExecutable,
|
|
...(shellResolution?.pathEnv ? { pathEnv: shellResolution.pathEnv } : {}),
|
|
};
|
|
}
|