this repo has no description
1import Foundation
2
3public enum PathPurpose: Hashable, Sendable {
4 case rpc
5 case tty
6 case nodeTooling
7}
8
9public struct PathDebugSnapshot: Equatable, Sendable {
10 public let codexBinary: String?
11 public let claudeBinary: String?
12 public let effectivePATH: String
13 public let loginShellPATH: String?
14
15 public static let empty = PathDebugSnapshot(
16 codexBinary: nil,
17 claudeBinary: nil,
18 effectivePATH: "",
19 loginShellPATH: nil)
20
21 public init(codexBinary: String?, claudeBinary: String?, effectivePATH: String, loginShellPATH: String?) {
22 self.codexBinary = codexBinary
23 self.claudeBinary = claudeBinary
24 self.effectivePATH = effectivePATH
25 self.loginShellPATH = loginShellPATH
26 }
27}
28
29public enum BinaryLocator {
30 public static func resolveClaudeBinary(
31 env: [String: String] = ProcessInfo.processInfo.environment,
32 loginPATH: [String]? = LoginShellPathCache.shared.current,
33 fileManager: FileManager = .default,
34 home: String = NSHomeDirectory()) -> String?
35 {
36 self.resolveBinary(
37 name: "claude",
38 overrideKey: "CLAUDE_CLI_PATH",
39 env: env,
40 loginPATH: loginPATH,
41 fileManager: fileManager,
42 home: home)
43 }
44
45 public static func resolveCodexBinary(
46 env: [String: String] = ProcessInfo.processInfo.environment,
47 loginPATH: [String]? = LoginShellPathCache.shared.current,
48 fileManager: FileManager = .default,
49 home: String = NSHomeDirectory()) -> String?
50 {
51 self.resolveBinary(
52 name: "codex",
53 overrideKey: "CODEX_CLI_PATH",
54 env: env,
55 loginPATH: loginPATH,
56 fileManager: fileManager,
57 home: home)
58 }
59
60 // swiftlint:disable function_parameter_count
61 private static func resolveBinary(
62 name: String,
63 overrideKey: String,
64 env: [String: String],
65 loginPATH: [String]?,
66 fileManager: FileManager,
67 home: String) -> String?
68 {
69 // swiftlint:enable function_parameter_count
70 // 1) Explicit override
71 if let override = env[overrideKey], fileManager.isExecutableFile(atPath: override) {
72 return override
73 }
74
75 // 2) Existing PATH
76 if let existingPATH = env["PATH"],
77 let pathHit = self.find(
78 name,
79 in: existingPATH.split(separator: ":").map(String.init),
80 fileManager: fileManager)
81 {
82 return pathHit
83 }
84
85 // 3) Login-shell PATH (captured once per launch)
86 if let loginPATH,
87 let pathHit = self.find(name, in: loginPATH, fileManager: fileManager)
88 {
89 return pathHit
90 }
91
92 // 4) Deterministic candidates
93 let directCandidates = [
94 "/opt/homebrew/bin/\(name)",
95 "/usr/local/bin/\(name)",
96 "\(home)/.local/bin/\(name)",
97 "\(home)/bin/\(name)",
98 "\(home)/.bun/bin/\(name)",
99 "\(home)/.npm-global/bin/\(name)",
100 ]
101 if let hit = directCandidates.first(where: { fileManager.isExecutableFile(atPath: $0) }) {
102 return hit
103 }
104
105 // 5) Version managers (bounded scan)
106 if let nvmHit = self.scanManagedVersions(
107 root: "\(home)/.nvm/versions/node",
108 binary: name,
109 fileManager: fileManager)
110 {
111 return nvmHit
112 }
113 if let fnmHit = self.scanManagedVersions(
114 root: "\(home)/.local/share/fnm",
115 binary: name,
116 fileManager: fileManager)
117 {
118 return fnmHit
119 }
120
121 return nil
122 }
123
124 public static func directories(
125 for purposes: Set<PathPurpose>,
126 env: [String: String],
127 loginPATH: [String]?,
128 fileManager: FileManager = .default,
129 home: String = NSHomeDirectory()) -> [String]
130 {
131 guard purposes.contains(.rpc) || purposes.contains(.tty) else { return [] }
132 var dirs: [String] = []
133 if let codex = self.resolveCodexBinary(
134 env: env,
135 loginPATH: loginPATH,
136 fileManager: fileManager,
137 home: home)
138 {
139 dirs.append(URL(fileURLWithPath: codex).deletingLastPathComponent().path)
140 }
141 if let claude = self.resolveClaudeBinary(
142 env: env,
143 loginPATH: loginPATH,
144 fileManager: fileManager,
145 home: home)
146 {
147 dirs.append(URL(fileURLWithPath: claude).deletingLastPathComponent().path)
148 }
149 return dirs
150 }
151
152 private static func find(_ binary: String, in paths: [String], fileManager: FileManager) -> String? {
153 for path in paths where !path.isEmpty {
154 let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)"
155 if fileManager.isExecutableFile(atPath: candidate) {
156 return candidate
157 }
158 }
159 return nil
160 }
161
162 private static func scanManagedVersions(root: String, binary: String, fileManager: FileManager) -> String? {
163 guard let versions = try? fileManager.contentsOfDirectory(atPath: root) else { return nil }
164 for version in versions.sorted(by: >) { // newest first
165 let candidate = "\(root)/\(version)/bin/\(binary)"
166 if fileManager.isExecutableFile(atPath: candidate) {
167 return candidate
168 }
169 }
170 return nil
171 }
172}
173
174public enum PathBuilder {
175 public static func effectivePATH(
176 purposes: Set<PathPurpose>,
177 env: [String: String] = ProcessInfo.processInfo.environment,
178 loginPATH: [String]? = LoginShellPathCache.shared.current,
179 resolvedBinaryPaths: [String]? = nil,
180 home: String = NSHomeDirectory()) -> String
181 {
182 var parts: [String] = []
183
184 if let existing = env["PATH"], !existing.isEmpty {
185 parts.append(contentsOf: existing.split(separator: ":").map(String.init))
186 } else {
187 parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"])
188 }
189
190 // Minimal static baseline
191 parts.append("/opt/homebrew/bin")
192 parts.append("/usr/local/bin")
193 parts.append("\(home)/.local/bin")
194 parts.append("\(home)/bin")
195 parts.append("\(home)/.bun/bin")
196 parts.append("\(home)/.npm-global/bin")
197 parts.append("\(home)/.local/share/fnm")
198 parts.append("\(home)/.fnm")
199
200 // Directories for resolved binaries
201 let binaries = resolvedBinaryPaths
202 ?? BinaryLocator.directories(for: purposes, env: env, loginPATH: loginPATH, home: home)
203 parts.append(contentsOf: binaries)
204
205 // Optional login-shell PATH captured once per launch
206 if let loginPATH {
207 parts.append(contentsOf: loginPATH)
208 }
209
210 var seen = Set<String>()
211 let deduped = parts.compactMap { part -> String? in
212 guard !part.isEmpty else { return nil }
213 if seen.insert(part).inserted {
214 return part
215 }
216 return nil
217 }
218
219 return deduped.joined(separator: ":")
220 }
221
222 public static func debugSnapshot(
223 purposes: Set<PathPurpose>,
224 env: [String: String] = ProcessInfo.processInfo.environment,
225 home: String = NSHomeDirectory()) -> PathDebugSnapshot
226 {
227 let login = LoginShellPathCache.shared.current
228 let effective = self.effectivePATH(
229 purposes: purposes,
230 env: env,
231 loginPATH: login,
232 home: home)
233 let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home)
234 let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home)
235 let loginString = login?.joined(separator: ":")
236 return PathDebugSnapshot(
237 codexBinary: codex,
238 claudeBinary: claude,
239 effectivePATH: effective,
240 loginShellPATH: loginString)
241 }
242}
243
244enum LoginShellPathCapturer {
245 static func capture(
246 shell: String? = ProcessInfo.processInfo.environment["SHELL"],
247 timeout: TimeInterval = 2.0) -> [String]?
248 {
249 let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh"
250 let process = Process()
251 process.executableURL = URL(fileURLWithPath: shellPath)
252 process.arguments = ["-l", "-c", "printf %s \"$PATH\""]
253 let stdout = Pipe()
254 process.standardOutput = stdout
255 process.standardError = Pipe()
256 do {
257 try process.run()
258 } catch {
259 return nil
260 }
261
262 let deadline = Date().addingTimeInterval(timeout)
263 while process.isRunning, Date() < deadline {
264 Thread.sleep(forTimeInterval: 0.05)
265 }
266
267 if process.isRunning {
268 process.terminate()
269 return nil
270 }
271
272 let data = stdout.fileHandleForReading.readDataToEndOfFile()
273 guard let text = String(data: data, encoding: .utf8)?
274 .trimmingCharacters(in: .whitespacesAndNewlines),
275 !text.isEmpty else { return nil }
276 return text.split(separator: ":").map(String.init)
277 }
278}
279
280public final class LoginShellPathCache: @unchecked Sendable {
281 public static let shared = LoginShellPathCache()
282
283 private let lock = NSLock()
284 private var captured: [String]?
285 private var isCapturing = false
286 private var callbacks: [([String]?) -> Void] = []
287
288 public var current: [String]? {
289 self.lock.lock()
290 let value = self.captured
291 self.lock.unlock()
292 return value
293 }
294
295 public func captureOnce(
296 shell: String? = ProcessInfo.processInfo.environment["SHELL"],
297 timeout: TimeInterval = 2.0,
298 onFinish: (([String]?) -> Void)? = nil)
299 {
300 self.lock.lock()
301 if let captured {
302 self.lock.unlock()
303 onFinish?(captured)
304 return
305 }
306
307 if let onFinish {
308 self.callbacks.append(onFinish)
309 }
310
311 if self.isCapturing {
312 self.lock.unlock()
313 return
314 }
315
316 self.isCapturing = true
317 self.lock.unlock()
318
319 DispatchQueue.global(qos: .utility).async { [weak self] in
320 let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout)
321 guard let self else { return }
322
323 self.lock.lock()
324 self.captured = result
325 self.isCapturing = false
326 let callbacks = self.callbacks
327 self.callbacks.removeAll()
328 self.lock.unlock()
329
330 callbacks.forEach { $0(result) }
331 }
332 }
333}