mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
531900cf70
* 🐛 fix(desktop): detect bundled Codex CLI from Codex.app on macOS OpenAI's Codex desktop app bundles the real codex CLI inside Codex.app (Contents/Resources/codex) but never symlinks it onto PATH. A user with only the desktop app installed failed PATH-based detection, so codex was never spawned and the chat silently produced no reply. Add a well-known install-location fallback inside detectHeterogeneousCliCommand (tried after the PATH lookup, so a user's own install still wins), covering both /Applications and ~/Applications. The fallback runs at detection time, not module load, so it touches no node:os named exports on import. Feed the detector-resolved absolute path through to spawn so a bare `codex` doesn't ENOENT under spawn's leaner env. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * 🐛 fix(desktop): carry login-shell PATH into CLI spawn env When the detector resolved a bare command via the login-shell PATH, only the absolute shim path was kept; the PATH used for resolution was dropped. spawn() then built its env from the leaner Finder-inherited PATH, so an absolute shim with `#!/usr/bin/env node` still failed with `env: node: No such file or directory` even though preflight succeeded (npm/Homebrew/mise installs launched from Finder on macOS). Surface the resolved PATH through ToolStatus.resolvedPathEnv, stash it on the session, and merge it into spawnEnv (session.env still wins). Only set when resolution fell back to the login-shell PATH, so the common on-PATH case is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { exec, execFile } from 'node:child_process';
|
|
import { homedir, platform } from 'node:os';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
|
|
import type { IToolDetector, ToolStatus } from '@/core/infrastructure/ToolDetectorManager';
|
|
import { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager';
|
|
|
|
const execFilePromise = promisify(execFile);
|
|
const execPromise = promisify(exec);
|
|
|
|
type HeterogeneousCliAgentType = 'claude-code' | 'codex';
|
|
|
|
interface ValidatedDetectorOptions {
|
|
description: string;
|
|
name: string;
|
|
priority: number;
|
|
validateFlag?: string;
|
|
validateKeywords: string[];
|
|
}
|
|
|
|
interface ResolvedCommand {
|
|
env?: NodeJS.ProcessEnv;
|
|
path: string;
|
|
}
|
|
|
|
const isWindows = () => platform() === 'win32';
|
|
let shellPathPromise: Promise<string | undefined> | undefined;
|
|
|
|
// Reject anything that could break out of the `cmd /c "<path>" --version`
|
|
// shell line we build for Windows .cmd shims (see `detectValidatedCommand`).
|
|
// User-supplied custom commands flow through here via `detectHeterogeneousCliCommand`.
|
|
const WINDOWS_SHELL_METAS = /[&|;<>^`!"]/;
|
|
|
|
// Extensions we can actually execute on Windows, in preference order:
|
|
// `.exe` runs directly via `execFile`, `.cmd` / `.bat` runs via `cmd.exe`.
|
|
// `.ps1` and extensionless wrappers (npm sometimes drops a Unix shell script
|
|
// next to the `.cmd` shim) are deliberately excluded — we can't run them.
|
|
const WINDOWS_RUNNABLE_EXTS = ['.exe', '.cmd', '.bat'] as const;
|
|
|
|
const pickWindowsRunnable = (lines: string[]): string | undefined => {
|
|
for (const ext of WINDOWS_RUNNABLE_EXTS) {
|
|
const match = lines.find((line) => line.toLowerCase().endsWith(ext));
|
|
if (match) return match;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const getLoginShellPath = async (): Promise<string | undefined> => {
|
|
if (isWindows()) return undefined;
|
|
|
|
const shell = process.env.SHELL;
|
|
if (!shell || !path.isAbsolute(shell)) return undefined;
|
|
|
|
try {
|
|
const { stdout } = await execFilePromise(shell, ['-ilc', 'printf "%s" "$PATH"'], {
|
|
timeout: 3000,
|
|
windowsHide: true,
|
|
});
|
|
|
|
return stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.reverse()
|
|
.find((line) => line.includes(path.delimiter));
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const getCachedLoginShellPath = async (): Promise<string | undefined> => {
|
|
shellPathPromise ??= getLoginShellPath();
|
|
return shellPathPromise;
|
|
};
|
|
|
|
const mergePathValues = (...values: Array<string | undefined>): string | undefined => {
|
|
const seen = new Set<string>();
|
|
const segments = values
|
|
.flatMap((value) => value?.split(path.delimiter) ?? [])
|
|
.map((segment) => segment.trim())
|
|
.filter((segment) => {
|
|
if (!segment || seen.has(segment)) return false;
|
|
seen.add(segment);
|
|
return true;
|
|
});
|
|
|
|
return segments.length > 0 ? segments.join(path.delimiter) : undefined;
|
|
};
|
|
|
|
const getCommandPathLines = async (
|
|
whichCommand: 'where' | 'which',
|
|
command: string,
|
|
env?: NodeJS.ProcessEnv,
|
|
): Promise<string[] | undefined> => {
|
|
try {
|
|
const { stdout } = await execFilePromise(whichCommand, [command], {
|
|
env,
|
|
timeout: 3000,
|
|
windowsHide: true,
|
|
});
|
|
const lines = stdout
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
|
|
return lines.length > 0 ? lines : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
const resolveCommandPath = async (command: string): Promise<ResolvedCommand | undefined> => {
|
|
const trimmedCommand = command.trim();
|
|
if (!trimmedCommand) return;
|
|
|
|
if (path.isAbsolute(trimmedCommand) || trimmedCommand.includes(path.sep)) {
|
|
return { path: trimmedCommand };
|
|
}
|
|
|
|
const whichCommand = isWindows() ? 'where' : 'which';
|
|
let lines = await getCommandPathLines(whichCommand, trimmedCommand);
|
|
let lookupEnv: NodeJS.ProcessEnv | undefined;
|
|
|
|
if (!lines && !isWindows()) {
|
|
const shellPath = await getCachedLoginShellPath();
|
|
const lookupPath = mergePathValues(shellPath, process.env.PATH);
|
|
|
|
if (lookupPath && lookupPath !== process.env.PATH) {
|
|
const fallbackEnv = {
|
|
...process.env,
|
|
PATH: lookupPath,
|
|
};
|
|
lines = await getCommandPathLines(whichCommand, trimmedCommand, fallbackEnv);
|
|
if (lines) lookupEnv = fallbackEnv;
|
|
}
|
|
}
|
|
|
|
if (!lines) return undefined;
|
|
|
|
// Windows `where` lists every PATHEXT match (e.g. for `codex` npm ships
|
|
// a Unix shell wrapper alongside `codex.cmd` and `codex.ps1`). Picking
|
|
// the first line can land us on something we can't execute, so prefer a
|
|
// runnable extension and bail otherwise.
|
|
if (isWindows()) {
|
|
const runnablePath = pickWindowsRunnable(lines);
|
|
return runnablePath ? { path: runnablePath } : undefined;
|
|
}
|
|
|
|
return { env: lookupEnv, path: lines[0] };
|
|
};
|
|
|
|
const detectValidatedCommand = async (
|
|
command: string,
|
|
options: Pick<ValidatedDetectorOptions, 'validateFlag' | 'validateKeywords'>,
|
|
): Promise<ToolStatus> => {
|
|
const trimmedCommand = command.trim();
|
|
if (!trimmedCommand) return { available: false };
|
|
if (isWindows() && WINDOWS_SHELL_METAS.test(trimmedCommand)) return { available: false };
|
|
|
|
const { validateFlag = '--version', validateKeywords } = options;
|
|
|
|
// Resolve via where/which BEFORE invoking. On Windows this is what discovers
|
|
// npm-installed shims like `claude.cmd` under %APPDATA%\npm — `execFile`
|
|
// alone won't apply PATHEXT and can't run .cmd files directly.
|
|
const resolvedCommand = await resolveCommandPath(trimmedCommand);
|
|
if (!resolvedCommand) return { available: false };
|
|
|
|
const { env, path: resolvedPath } = resolvedCommand;
|
|
|
|
try {
|
|
const needsShell = isWindows() && /\.(?:cmd|bat)$/i.test(resolvedPath);
|
|
const { stderr, stdout } = needsShell
|
|
? await execPromise(`"${resolvedPath}" ${validateFlag}`, {
|
|
env,
|
|
timeout: 5000,
|
|
windowsHide: true,
|
|
})
|
|
: await execFilePromise(resolvedPath, [validateFlag], {
|
|
env,
|
|
timeout: 5000,
|
|
windowsHide: true,
|
|
});
|
|
const output = `${stdout}\n${stderr}`.trim();
|
|
const loweredOutput = output.toLowerCase();
|
|
|
|
if (!validateKeywords.some((keyword) => loweredOutput.includes(keyword.toLowerCase()))) {
|
|
return { available: false };
|
|
}
|
|
|
|
return {
|
|
available: true,
|
|
path: resolvedPath,
|
|
// `env` is set only when resolution fell back to the login-shell PATH.
|
|
// Surface that PATH so the spawn site can carry it into the child env —
|
|
// otherwise a `#!/usr/bin/env node` shim resolved here can't find `node`
|
|
// under the leaner inherited PATH (Finder-launched Electron).
|
|
resolvedPathEnv: env?.PATH,
|
|
version: output.split(/\r?\n/)[0],
|
|
};
|
|
} catch {
|
|
return { available: false };
|
|
}
|
|
};
|
|
|
|
const HETEROGENEOUS_CLI_AGENT_OPTIONS = {
|
|
'claude-code': {
|
|
validateKeywords: ['claude code'],
|
|
},
|
|
'codex': {
|
|
validateKeywords: ['codex'],
|
|
},
|
|
} as const satisfies Record<
|
|
HeterogeneousCliAgentType,
|
|
Pick<ValidatedDetectorOptions, 'validateKeywords'>
|
|
>;
|
|
|
|
// Well-known absolute install locations probed when a bare command isn't on
|
|
// PATH. The Codex desktop app bundles a fully functional CLI inside Codex.app
|
|
// (sharing ~/.codex auth/config) but never symlinks it into PATH, so
|
|
// `which codex` misses an otherwise working install.
|
|
const getWellKnownCommandPaths = (agentType: HeterogeneousCliAgentType): string[] => {
|
|
if (platform() !== 'darwin') return [];
|
|
|
|
switch (agentType) {
|
|
case 'codex': {
|
|
const bundledCli = path.join('Codex.app', 'Contents', 'Resources', 'codex');
|
|
return [
|
|
path.join('/Applications', bundledCli),
|
|
path.join(homedir(), 'Applications', bundledCli),
|
|
];
|
|
}
|
|
default: {
|
|
return [];
|
|
}
|
|
}
|
|
};
|
|
|
|
export const detectHeterogeneousCliCommand = async (
|
|
agentType: HeterogeneousCliAgentType,
|
|
command: string,
|
|
): Promise<ToolStatus> => {
|
|
const validator = HETEROGENEOUS_CLI_AGENT_OPTIONS[agentType];
|
|
if (!validator) return { available: false };
|
|
|
|
const status = await detectValidatedCommand(command, validator);
|
|
if (status.available) return status;
|
|
|
|
// A bare command missing from PATH may still live at a well-known install
|
|
// location (e.g. the Codex desktop app's bundled CLI). Don't second-guess
|
|
// an explicit user-configured path.
|
|
if (!command.trim().includes(path.sep)) {
|
|
for (const candidate of getWellKnownCommandPaths(agentType)) {
|
|
const fallbackStatus = await detectValidatedCommand(candidate, validator);
|
|
if (fallbackStatus.available) return fallbackStatus;
|
|
}
|
|
}
|
|
|
|
return status;
|
|
};
|
|
|
|
/**
|
|
* Detector that resolves a command path via which/where, then validates
|
|
* the binary by matching `--version` (or `--help`) output against a keyword
|
|
* to avoid collisions with unrelated executables of the same name.
|
|
*/
|
|
const createValidatedDetector = (
|
|
options: ValidatedDetectorOptions & {
|
|
candidates: string[];
|
|
},
|
|
): IToolDetector => {
|
|
const { candidates, description, name, priority, ...validation } = options;
|
|
|
|
return {
|
|
description,
|
|
async detect(): Promise<ToolStatus> {
|
|
for (const cmd of candidates) {
|
|
const status = await detectValidatedCommand(cmd, validation);
|
|
if (status.available) return status;
|
|
}
|
|
|
|
return { available: false };
|
|
},
|
|
name,
|
|
priority,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Claude Code CLI
|
|
* @see https://docs.claude.com/en/docs/claude-code
|
|
*/
|
|
export const claudeCodeDetector: IToolDetector = createValidatedDetector({
|
|
candidates: ['claude'],
|
|
description: 'Claude Code - Anthropic official agentic coding CLI',
|
|
name: 'claude',
|
|
priority: 1,
|
|
validateKeywords: ['claude code'],
|
|
});
|
|
|
|
/**
|
|
* OpenAI Codex CLI
|
|
* @see https://github.com/openai/codex
|
|
*
|
|
* Goes through `detectHeterogeneousCliCommand` so the Codex.app bundled-CLI
|
|
* fallback applies here too, keeping the manager path and the custom-command
|
|
* path in sync.
|
|
*/
|
|
export const codexDetector: IToolDetector = {
|
|
description: 'Codex - OpenAI agentic coding CLI',
|
|
detect: () => detectHeterogeneousCliCommand('codex', 'codex'),
|
|
name: 'codex',
|
|
priority: 2,
|
|
};
|
|
|
|
/**
|
|
* Google Gemini CLI
|
|
* @see https://github.com/google-gemini/gemini-cli
|
|
*/
|
|
export const geminiCliDetector: IToolDetector = createValidatedDetector({
|
|
candidates: ['gemini'],
|
|
description: 'Gemini CLI - Google agentic coding CLI',
|
|
name: 'gemini',
|
|
priority: 3,
|
|
validateKeywords: ['gemini'],
|
|
});
|
|
|
|
/**
|
|
* Qwen Code CLI
|
|
* @see https://github.com/QwenLM/qwen-code
|
|
*/
|
|
export const qwenCodeDetector: IToolDetector = createValidatedDetector({
|
|
candidates: ['qwen'],
|
|
description: 'Qwen Code - Alibaba Qwen agentic coding CLI',
|
|
name: 'qwen',
|
|
priority: 4,
|
|
validateKeywords: ['qwen'],
|
|
});
|
|
|
|
/**
|
|
* Kimi CLI (Moonshot)
|
|
* @see https://github.com/MoonshotAI/kimi-cli
|
|
*/
|
|
export const kimiCliDetector: IToolDetector = createValidatedDetector({
|
|
candidates: ['kimi'],
|
|
description: 'Kimi CLI - Moonshot AI agentic coding CLI',
|
|
name: 'kimi',
|
|
priority: 5,
|
|
validateKeywords: ['kimi'],
|
|
});
|
|
|
|
/**
|
|
* Aider - AI pair programming CLI
|
|
* Generic command detector; name collision is unlikely.
|
|
* @see https://github.com/Aider-AI/aider
|
|
*/
|
|
export const aiderDetector: IToolDetector = createCommandDetector('aider', {
|
|
description: 'Aider - AI pair programming in your terminal',
|
|
priority: 6,
|
|
});
|
|
|
|
/**
|
|
* All CLI agent detectors
|
|
*/
|
|
export const cliAgentDetectors: IToolDetector[] = [
|
|
claudeCodeDetector,
|
|
codexDetector,
|
|
geminiCliDetector,
|
|
qwenCodeDetector,
|
|
kimiCliDetector,
|
|
aiderDetector,
|
|
];
|