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 // fnm: check default alias first, then scan node-versions
114 let fnmDefaultBin = "\(home)/.local/share/fnm/aliases/default/bin/\(name)"
115 if fileManager.isExecutableFile(atPath: fnmDefaultBin) {
116 return fnmDefaultBin
117 }
118 if let fnmHit = self.scanFnmVersions(
119 root: "\(home)/.local/share/fnm/node-versions",
120 binary: name,
121 fileManager: fileManager)
122 {
123 return fnmHit
124 }
125
126 return nil
127 }
128
129 public static func directories(
130 for purposes: Set<PathPurpose>,
131 env: [String: String],
132 loginPATH: [String]?,
133 fileManager: FileManager = .default,
134 home: String = NSHomeDirectory()) -> [String]
135 {
136 guard purposes.contains(.rpc) || purposes.contains(.tty) else { return [] }
137 var dirs: [String] = []
138 if let codex = self.resolveCodexBinary(
139 env: env,
140 loginPATH: loginPATH,
141 fileManager: fileManager,
142 home: home)
143 {
144 dirs.append(URL(fileURLWithPath: codex).deletingLastPathComponent().path)
145 }
146 if let claude = self.resolveClaudeBinary(
147 env: env,
148 loginPATH: loginPATH,
149 fileManager: fileManager,
150 home: home)
151 {
152 dirs.append(URL(fileURLWithPath: claude).deletingLastPathComponent().path)
153 }
154 return dirs
155 }
156
157 private static func find(_ binary: String, in paths: [String], fileManager: FileManager) -> String? {
158 for path in paths where !path.isEmpty {
159 let candidate = "\(path.hasSuffix("/") ? String(path.dropLast()) : path)/\(binary)"
160 if fileManager.isExecutableFile(atPath: candidate) {
161 return candidate
162 }
163 }
164 return nil
165 }
166
167 private static func scanManagedVersions(root: String, binary: String, fileManager: FileManager) -> String? {
168 guard let versions = try? fileManager.contentsOfDirectory(atPath: root) else { return nil }
169 for version in versions.sorted(by: >) { // newest first
170 let candidate = "\(root)/\(version)/bin/\(binary)"
171 if fileManager.isExecutableFile(atPath: candidate) {
172 return candidate
173 }
174 }
175 return nil
176 }
177
178 /// fnm uses node-versions/v*/installation/bin/ structure
179 private static func scanFnmVersions(root: String, binary: String, fileManager: FileManager) -> String? {
180 guard let versions = try? fileManager.contentsOfDirectory(atPath: root) else { return nil }
181 for version in versions.sorted(by: >) { // newest first
182 let candidate = "\(root)/\(version)/installation/bin/\(binary)"
183 if fileManager.isExecutableFile(atPath: candidate) {
184 return candidate
185 }
186 }
187 return nil
188 }
189}
190
191public enum PathBuilder {
192 public static func effectivePATH(
193 purposes: Set<PathPurpose>,
194 env: [String: String] = ProcessInfo.processInfo.environment,
195 loginPATH: [String]? = LoginShellPathCache.shared.current,
196 resolvedBinaryPaths: [String]? = nil,
197 home: String = NSHomeDirectory()) -> String
198 {
199 var parts: [String] = []
200
201 if let existing = env["PATH"], !existing.isEmpty {
202 parts.append(contentsOf: existing.split(separator: ":").map(String.init))
203 } else {
204 parts.append(contentsOf: ["/usr/bin", "/bin", "/usr/sbin", "/sbin"])
205 }
206
207 // Minimal static baseline
208 parts.append("/opt/homebrew/bin")
209 parts.append("/usr/local/bin")
210 parts.append("\(home)/.local/bin")
211 parts.append("\(home)/bin")
212 parts.append("\(home)/.bun/bin")
213 parts.append("\(home)/.npm-global/bin")
214 // fnm: add aliases/default/bin so node is available for node-based CLI scripts
215 parts.append("\(home)/.local/share/fnm/aliases/default/bin")
216 // nvm: add default alias bin for node availability
217 parts.append("\(home)/.nvm/alias/default/bin")
218
219 // Directories for resolved binaries
220 let binaries = resolvedBinaryPaths
221 ?? BinaryLocator.directories(for: purposes, env: env, loginPATH: loginPATH, home: home)
222 parts.append(contentsOf: binaries)
223
224 // Optional login-shell PATH captured once per launch
225 if let loginPATH {
226 parts.append(contentsOf: loginPATH)
227 }
228
229 var seen = Set<String>()
230 let deduped = parts.compactMap { part -> String? in
231 guard !part.isEmpty else { return nil }
232 if seen.insert(part).inserted {
233 return part
234 }
235 return nil
236 }
237
238 return deduped.joined(separator: ":")
239 }
240
241 public static func debugSnapshot(
242 purposes: Set<PathPurpose>,
243 env: [String: String] = ProcessInfo.processInfo.environment,
244 home: String = NSHomeDirectory()) -> PathDebugSnapshot
245 {
246 let login = LoginShellPathCache.shared.current
247 let effective = self.effectivePATH(
248 purposes: purposes,
249 env: env,
250 loginPATH: login,
251 home: home)
252 let codex = BinaryLocator.resolveCodexBinary(env: env, loginPATH: login, home: home)
253 let claude = BinaryLocator.resolveClaudeBinary(env: env, loginPATH: login, home: home)
254 let loginString = login?.joined(separator: ":")
255 return PathDebugSnapshot(
256 codexBinary: codex,
257 claudeBinary: claude,
258 effectivePATH: effective,
259 loginShellPATH: loginString)
260 }
261}
262
263enum LoginShellPathCapturer {
264 static func capture(
265 shell: String? = ProcessInfo.processInfo.environment["SHELL"],
266 timeout: TimeInterval = 2.0) -> [String]?
267 {
268 let shellPath = (shell?.isEmpty == false) ? shell! : "/bin/zsh"
269 let process = Process()
270 process.executableURL = URL(fileURLWithPath: shellPath)
271 process.arguments = ["-l", "-c", "printf %s \"$PATH\""]
272 let stdout = Pipe()
273 process.standardOutput = stdout
274 process.standardError = Pipe()
275 do {
276 try process.run()
277 } catch {
278 return nil
279 }
280
281 let deadline = Date().addingTimeInterval(timeout)
282 while process.isRunning, Date() < deadline {
283 Thread.sleep(forTimeInterval: 0.05)
284 }
285
286 if process.isRunning {
287 process.terminate()
288 return nil
289 }
290
291 let data = stdout.fileHandleForReading.readDataToEndOfFile()
292 guard let text = String(data: data, encoding: .utf8)?
293 .trimmingCharacters(in: .whitespacesAndNewlines),
294 !text.isEmpty else { return nil }
295 return text.split(separator: ":").map(String.init)
296 }
297}
298
299public final class LoginShellPathCache: @unchecked Sendable {
300 public static let shared = LoginShellPathCache()
301
302 private let lock = NSLock()
303 private var captured: [String]?
304 private var isCapturing = false
305 private var callbacks: [([String]?) -> Void] = []
306
307 public var current: [String]? {
308 self.lock.lock()
309 let value = self.captured
310 self.lock.unlock()
311 return value
312 }
313
314 public func captureOnce(
315 shell: String? = ProcessInfo.processInfo.environment["SHELL"],
316 timeout: TimeInterval = 2.0,
317 onFinish: (([String]?) -> Void)? = nil)
318 {
319 self.lock.lock()
320 if let captured {
321 self.lock.unlock()
322 onFinish?(captured)
323 return
324 }
325
326 if let onFinish {
327 self.callbacks.append(onFinish)
328 }
329
330 if self.isCapturing {
331 self.lock.unlock()
332 return
333 }
334
335 self.isCapturing = true
336 self.lock.unlock()
337
338 DispatchQueue.global(qos: .utility).async { [weak self] in
339 let result = LoginShellPathCapturer.capture(shell: shell, timeout: timeout)
340 guard let self else { return }
341
342 self.lock.lock()
343 self.captured = result
344 self.isCapturing = false
345 let callbacks = self.callbacks
346 self.callbacks.removeAll()
347 self.lock.unlock()
348
349 callbacks.forEach { $0(result) }
350 }
351 }
352}