import Foundation public enum PathPurpose: Hashable, Sendable { case rpc case tty case nodeTooling} public struct PathDebugSnapshot: Equatable, Sendable { public let codexBinary: String? public let claudeBinary: String? public let geminiBinary: String? public let effectivePATH: String public let loginShellPATH: String? public static let empty = PathDebugSnapshot( codexBinary: nil, claudeBinary: nil, geminiBinary: nil, effectivePATH: "", loginShellPATH: nil) public init( codexBinary: String?, claudeBinary: String?, geminiBinary: String? = nil, effectivePATH: String, loginShellPATH: String?) { self.codexBinary = codexBinary self.claudeBinary = claudeBinary self.geminiBinary = geminiBinary self.effectivePATH = effectivePATH self.loginShellPATH = loginShellPATH }} public enum BinaryLocator { public static func resolveClaudeBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "claude", overrideKey: "CLAUDE_CLI_PATH", env: env, loginPATH: loginPATH, fileManager: fileManager, home: home) } public static func resolveCodexBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "codex", overrideKey: "CODEX_CLI_PATH", env: env, loginPATH: loginPATH, fileManager: fileManager, home: home) } public static func resolveGeminiBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "gemini", overrideKey: "GEMINI_CLI_PATH", env: env, loginPATH: loginPATH, fileManager: fileManager, home: home) } // swiftlint:disable function_parameter_count private static func resolveBinary( name: String, overrideKey: String, env: [String: String], loginPATH: [String]?, fileManager: FileManager, home _: String) -> String? { // swiftlint:enable function_parameter_count // 1) Explicit override if let override = env[overrideKey], fileManager.isExecutableFile(atPath: override) { return override } // 2) Login-shell PATH (captured once per launch) if let loginPATH, let pathHit = self.find(name, in: loginPATH, fileManager: fileManager) { return pathHit } // 3) Existing PATH if let existingPATH = env["PATH"], let pathHit = self.find( name, in: existingPATH.split(separator: ":").map(String.init), fileManager: fileManager) { return pathHit } // 4) Minimal fallback let fallback = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"] if let pathHit = self.find(name, in: fallback, fileManager: fileManager) { return pathHit } return nil } private static func find(_ binary: String, in paths: [String], fileManager: FileManager) -> String? { for path in paths where !path.isEmpty { let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)" if fileManager.isExecutableFile(atPath: candidate) { return candidate } } return nil }} public enum PathBuilder { public static func effectivePATH( purposes _: Set<PathPurpose>, env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, home _: String = NSHomeDirectory()) -> String { if let loginPATH, !loginPATH.isEmpty { return loginPATH.joined(separator: ":") } if let existing = env["PATH"], !existing.isEmpty { return existing } return ["/usr/bin", "/bin", "/usr/sbin", "/sbin"].joined(separator: ":") } public static func debugSnapshot( purposes: Set<PathPurpose>, env: [String: String] = ProcessInfo.processInfo.environment, home: String = NSHomeDirectory()) -> PathDebugSnapshot { let login = LoginShellPathCache.shared.current let effective = self.effectivePATH( purposes: purposes, env: env, loginPATH: login, home: home) let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home) let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home) let gemini = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: login, home: home) let loginString = login?.joined(separator: ":") return PathDebugSnapshot( codexBinary: codex, claudeBinary: claude, geminiBinary: gemini, effectivePATH: effective, loginShellPATH: loginString) }} enum LoginShellPathCapturer { static func capture( shell: String? = ProcessInfo.processInfo.environment["SHELL"], timeout: TimeInterval = 2.0) -> [String]? { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let process = Process() process.executableURL = URL(fileURLWithPath: shellPath) process.arguments = ["-l", "-c", "printf %s \"$PATH\""] let stdout = Pipe() process.standardOutput = stdout process.standardError = Pipe() do { try process.run() } catch { return nil } let deadline = Date().addingTimeInterval(timeout) while process.isRunning, Date() < deadline { Thread.sleep(forTimeInterval: 0.05) } if process.isRunning { process.terminate() return nil } let data = stdout.fileHandleForReading.readDataToEndOfFile() guard let text = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { return nil } return text.split(separator: ":").map(String.init) }} public final class LoginShellPathCache: @unchecked Sendable { public static let shared = LoginShellPathCache() private let lock = NSLock() private var captured: [String]? private var isCapturing = false private var callbacks: [([String]?) -> Void] = [] public var current: [String]? { self.lock.lock() let value = self.captured self.lock.unlock() return value } public func captureOnce( shell: String? = ProcessInfo.processInfo.environment["SHELL"], timeout: TimeInterval = 2.0, onFinish: (([String]?) -> Void)? = nil) { self.lock.lock() if let captured { self.lock.unlock() onFinish?(captured) return } if let onFinish { self.callbacks.append(onFinish) } if self.isCapturing { self.lock.unlock() return } self.isCapturing = true self.lock.unlock() DispatchQueue.global(qos: .utility).async { [weak self] in let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout) guard let self else { return } self.lock.lock() self.captured = result self.isCapturing = false let callbacks = self.callbacks self.callbacks.removeAll() self.lock.unlock() callbacks.forEach { $0(result) } } }}
import Foundation
public enum PathPurpose: Hashable, Sendable {
case rpc
case tty
case nodeTooling
}
public struct PathDebugSnapshot: Equatable, Sendable {
public let codexBinary: String?
public let claudeBinary: String?
public let geminiBinary: String?
public let effectivePATH: String
public let loginShellPATH: String?
public static let empty = PathDebugSnapshot(
codexBinary: nil,
claudeBinary: nil,
geminiBinary: nil,
effectivePATH: "",
loginShellPATH: nil)
public init(
codexBinary: String?,
claudeBinary: String?,
geminiBinary: String? = nil,
effectivePATH: String,
loginShellPATH: String?)
{
self.codexBinary = codexBinary
self.claudeBinary = claudeBinary
self.geminiBinary = geminiBinary
self.effectivePATH = effectivePATH
self.loginShellPATH = loginShellPATH
}
}
public enum BinaryLocator {
public static func resolveClaudeBinary(
env: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current,
fileManager: FileManager = .default,
home: String = NSHomeDirectory()) -> String?
{
self.resolveBinary(
name: "claude",
overrideKey: "CLAUDE_CLI_PATH",
env: env,
loginPATH: loginPATH,
fileManager: fileManager,
home: home)
}
public static func resolveCodexBinary(
env: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current,
fileManager: FileManager = .default,
home: String = NSHomeDirectory()) -> String?
{
self.resolveBinary(
name: "codex",
overrideKey: "CODEX_CLI_PATH",
env: env,
loginPATH: loginPATH,
fileManager: fileManager,
home: home)
}
public static func resolveGeminiBinary(
env: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current,
fileManager: FileManager = .default,
home: String = NSHomeDirectory()) -> String?
{
self.resolveBinary(
name: "gemini",
overrideKey: "GEMINI_CLI_PATH",
env: env,
loginPATH: loginPATH,
fileManager: fileManager,
home: home)
}
// swiftlint:disable function_parameter_count
private static func resolveBinary(
name: String,
overrideKey: String,
env: [String: String],
loginPATH: [String]?,
fileManager: FileManager,
home _: String) -> String?
{
// swiftlint:enable function_parameter_count
// 1) Explicit override
if let override = env[overrideKey], fileManager.isExecutableFile(atPath: override) {
return override
}
// 2) Login-shell PATH (captured once per launch)
if let loginPATH,
let pathHit = self.find(name, in: loginPATH, fileManager: fileManager)
{
return pathHit
}
// 3) Existing PATH
if let existingPATH = env["PATH"],
let pathHit = self.find(
name,
in: existingPATH.split(separator: ":").map(String.init),
fileManager: fileManager)
{
return pathHit
}
// 4) Minimal fallback
let fallback = ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
if let pathHit = self.find(name, in: fallback, fileManager: fileManager) {
return pathHit
}
return nil
}
private static func find(_ binary: String, in paths: [String], fileManager: FileManager) -> String? {
for path in paths where !path.isEmpty {
let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)"
if fileManager.isExecutableFile(atPath: candidate) {
return candidate
}
}
return nil
}
}
public enum PathBuilder {
public static func effectivePATH(
purposes _: Set<PathPurpose>,
env: [String: String] = ProcessInfo.processInfo.environment,
loginPATH: [String]? = LoginShellPathCache.shared.current,
home _: String = NSHomeDirectory()) -> String
{
if let loginPATH, !loginPATH.isEmpty {
return loginPATH.joined(separator: ":")
}
if let existing = env["PATH"], !existing.isEmpty {
return existing
}
return ["/usr/bin", "/bin", "/usr/sbin", "/sbin"].joined(separator: ":")
}
public static func debugSnapshot(
purposes: Set<PathPurpose>,
env: [String: String] = ProcessInfo.processInfo.environment,
home: String = NSHomeDirectory()) -> PathDebugSnapshot
{
let login = LoginShellPathCache.shared.current
let effective = self.effectivePATH(
purposes: purposes,
env: env,
loginPATH: login,
home: home)
let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home)
let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home)
let gemini = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: login, home: home)
let loginString = login?.joined(separator: ":")
return PathDebugSnapshot(
codexBinary: codex,
claudeBinary: claude,
geminiBinary: gemini,
effectivePATH: effective,
loginShellPATH: loginString)
}
}
enum LoginShellPathCapturer {
static func capture(
shell: String? = ProcessInfo.processInfo.environment["SHELL"],
timeout: TimeInterval = 2.0) -> [String]?
{
let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh"
let process = Process()
process.executableURL = URL(fileURLWithPath: shellPath)
process.arguments = ["-l", "-c", "printf %s \"$PATH\""]
let stdout = Pipe()
process.standardOutput = stdout
process.standardError = Pipe()
do {
try process.run()
} catch {
return nil
}
let deadline = Date().addingTimeInterval(timeout)
while process.isRunning, Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if process.isRunning {
process.terminate()
return nil
}
let data = stdout.fileHandleForReading.readDataToEndOfFile()
guard let text = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty else { return nil }
return text.split(separator: ":").map(String.init)
}
}
public final class LoginShellPathCache: @unchecked Sendable {
public static let shared = LoginShellPathCache()
private let lock = NSLock()
private var captured: [String]?
private var isCapturing = false
private var callbacks: [([String]?) -> Void] = []
public var current: [String]? {
self.lock.lock()
let value = self.captured
self.lock.unlock()
return value
}
public func captureOnce(
shell: String? = ProcessInfo.processInfo.environment["SHELL"],
timeout: TimeInterval = 2.0,
onFinish: (([String]?) -> Void)? = nil)
{
self.lock.lock()
if let captured {
self.lock.unlock()
onFinish?(captured)
return
}
if let onFinish {
self.callbacks.append(onFinish)
}
if self.isCapturing {
self.lock.unlock()
return
}
self.isCapturing = true
self.lock.unlock()
DispatchQueue.global(qos: .utility).async { [weak self] in
let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout)
guard let self else { return }
self.lock.lock()
self.captured = result
self.isCapturing = false
let callbacks = self.callbacks
self.callbacks.removeAll()
self.lock.unlock()
callbacks.forEach { $0(result) }
}
}
}