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, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "claude", overrideKey: "CLAUDE_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, fileManager: fileManager, home: home) } public static func resolveCodexBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "codex", overrideKey: "CODEX_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, fileManager: fileManager, home: home) } public static func resolveGeminiBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "gemini", overrideKey: "GEMINI_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, fileManager: fileManager, home: home) } public static func resolveAuggieBinary( env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, commandV: (String, String?, TimeInterval, FileManager) -> String? = ShellCommandLocator.commandV, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> String? = ShellCommandLocator .resolveAlias, fileManager: FileManager = .default, home: String = NSHomeDirectory()) -> String? { self.resolveBinary( name: "auggie", overrideKey: "AUGGIE_CLI_PATH", env: env, loginPATH: loginPATH, commandV: commandV, aliasResolver: aliasResolver, fileManager: fileManager, home: home) } // swiftlint:disable function_parameter_count private static func resolveBinary( name: String, overrideKey: String, env: [String: String], loginPATH: [String]?, commandV: (String, String?, TimeInterval, FileManager) -> String?, aliasResolver: (String, String?, TimeInterval, FileManager, String) -> 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) Interactive login shell lookup (captures nvm/fnm/mise paths from .zshrc/.bashrc) if let shellHit = commandV(name, env["SHELL"], 2.0, fileManager), fileManager.isExecutableFile(atPath: shellHit) { return shellHit } // 4b) Alias fallback (login shell); only attempt after all standard lookups fail. if let aliasHit = aliasResolver(name, env["SHELL"], 2.0, fileManager, home), fileManager.isExecutableFile(atPath: aliasHit) { return aliasHit } // 5) 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 ShellCommandLocator { public static func commandV( _ tool: String, _ shell: String?, _ timeout: TimeInterval, _ fileManager: FileManager) -> String? { let text = self.runShellCapture(shell, timeout, "command -v \(tool)")? .trimmingCharacters(in: .whitespacesAndNewlines) guard let text, !text.isEmpty else { return nil } let lines = text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } for line in lines.reversed() where line.hasPrefix("/") { let path = line if fileManager.isExecutableFile(atPath: path) { return path } } return nil } public static func resolveAlias( _ tool: String, _ shell: String?, _ timeout: TimeInterval, _ fileManager: FileManager, _ home: String) -> String? { let command = "alias \(tool) 2>/dev/null; type -a \(tool) 2>/dev/null" guard let text = self.runShellCapture(shell, timeout, command) else { return nil } let lines = text.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } if let aliasPath = self.parseAliasPath(lines, tool: tool, home: home, fileManager: fileManager) { return aliasPath } for line in lines { if let path = self.extractPathCandidate(line: line, tool: tool, home: home), fileManager.isExecutableFile(atPath: path) { return path } } return nil } private static func runShellCapture(_ shell: String?, _ timeout: TimeInterval, _ command: String) -> String? { let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh" let isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) let process = Process() process.executableURL = URL(fileURLWithPath: shellPath) // Interactive login shell to pick up PATH mutations from shell init (nvm/fnm/mise). // CI runners can have shell init hooks that emit missing CLI errors; avoid them in CI. process.arguments = isCI ? ["-c", command] : ["-l", "-i", "-c", command] 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() return String(data: data, encoding: .utf8) } private static func parseAliasPath( _ lines: [String], tool: String, home: String, fileManager: FileManager) -> String? { for line in lines { if line.hasPrefix("alias \(tool)=") { let value = line.replacingOccurrences(of: "alias \(tool)=", with: "") if let path = self.extractAliasExpansion(value, home: home), fileManager.isExecutableFile(atPath: path) { return path } } if line.lowercased().contains("aliased to") { if let range = line.range(of: "aliased to") { let value = line[range.upperBound...].trimmingCharacters(in: .whitespacesAndNewlines) if let path = self.extractAliasExpansion(String(value), home: home), fileManager.isExecutableFile(atPath: path) { return path } } } } return nil } private static func extractAliasExpansion(_ raw: String, home: String) -> String? { let trimmed = raw.trimmingCharacters(in: CharacterSet(charactersIn: " \t\"'`")) guard !trimmed.isEmpty else { return nil } let parts = trimmed.split(separator: " ").map(String.init) guard let first = parts.first else { return nil } return self.expandPath(first, home: home) } private static func extractPathCandidate(line: String, tool: String, home: String) -> String? { let tokens = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init) for token in tokens { let candidate = self.expandPath(token, home: home) if candidate.hasPrefix("/"), URL(fileURLWithPath: candidate).lastPathComponent == tool { return candidate } } return nil } private static func expandPath(_ raw: String, home: String) -> String { if raw == "~" { return home } if raw.hasPrefix("~/") { return home + String(raw.dropFirst()) } return raw } } public enum PathBuilder { public static func effectivePATH( purposes _: Set, env: [String: String] = ProcessInfo.processInfo.environment, loginPATH: [String]? = LoginShellPathCache.shared.current, home _: String = NSHomeDirectory()) -> String { var parts: [String] = [] if let loginPATH, !loginPATH.isEmpty { parts.append(contentsOf: loginPATH) } if let existing = env["PATH"], !existing.isEmpty { parts.append(contentsOf: existing.split(separator: ":").map(String.init)) } if parts.isEmpty { parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]) } var seen = Set() let deduped = parts.compactMap { part -> String? in guard !part.isEmpty else { return nil } if seen.insert(part).inserted { return part } return nil } return deduped.joined(separator: ":") } public static func debugSnapshot( purposes: Set, 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) } public static func debugSnapshotAsync( purposes: Set, env: [String: String] = ProcessInfo.processInfo.environment, home: String = NSHomeDirectory()) async -> PathDebugSnapshot { await Task.detached(priority: .userInitiated) { self.debugSnapshot(purposes: purposes, env: env, home: home) }.value } } 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 isCI = ["1", "true"].contains(ProcessInfo.processInfo.environment["CI"]?.lowercased()) let marker = "__CODEXBAR_PATH__" let process = Process() process.executableURL = URL(fileURLWithPath: shellPath) // Skip interactive login shells in CI to avoid noisy init hooks. process.arguments = isCI ? ["-c", "printf '\(marker)%s\(marker)' \"$PATH\""] : ["-l", "-i", "-c", "printf '\(marker)%s\(marker)' \"$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 raw = String(data: data, encoding: .utf8), !raw.isEmpty else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) let extracted = if let start = trimmed.range(of: marker), let end = trimmed.range(of: marker, options: .backwards), start.upperBound <= end.lowerBound { String(trimmed[start.upperBound.. 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) } } } }