267 lines · 8.2 KB · Swift
RawDownload
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) }        }    }}