#if canImport(Darwin)import Darwin#elseimport Glibc#endifimport Foundation public enum ProviderVersionDetector { public static func claudeVersion() -> String? { guard let path = TTYCommandRunner.which("claude") else { return nil } do { let out = try TTYCommandRunner().run( binary: path, send: "", options: TTYCommandRunner.Options( timeout: 5.0, extraArgs: ["--allowed-tools", "", "--version"], initialDelay: 0.0, useClaudeProbeWorkingDirectory: true)).text let trimmed = TextParsing.stripANSICodes(out).trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } catch { return nil } } public static func codexVersion() -> String? { guard let path = TTYCommandRunner.which("codex") else { return nil } let candidates = [ ["--version"], ["version"], ["-v"], ] for args in candidates { if let version = Self.run(path: path, args: args) { return version } } return nil } public static func geminiVersion() -> String? { let env = ProcessInfo.processInfo.environment guard let path = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: nil) ?? TTYCommandRunner.which("gemini") else { return nil } let candidates = [ ["--version"], ["-v"], ] for args in candidates { if let version = Self.run(path: path, args: args) { return version } } return nil } static func run(path: String, args: [String], timeout: TimeInterval = 2.0) -> String? { let proc = Process() proc.executableURL = URL(fileURLWithPath: path) proc.arguments = args let out = Pipe() proc.standardOutput = out proc.standardError = Pipe() proc.standardInput = nil let exitSemaphore = DispatchSemaphore(value: 0) proc.terminationHandler = { _ in exitSemaphore.signal() } do { try proc.run() } catch { return nil } let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) { return nil } let data = out.fileHandleForReading.readDataToEndOfFile() guard proc.terminationStatus == 0, let text = String(data: data, encoding: .utf8)? .split(whereSeparator: \.isNewline).first else { return nil } let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool { guard proc.isRunning else { return true } proc.terminate() if exitSemaphore.wait(timeout: .now() + 0.5) == .success { return true } guard proc.isRunning else { return true } kill(proc.processIdentifier, SIGKILL) return exitSemaphore.wait(timeout: .now() + 1.0) == .success }}
#if canImport(Darwin)
import Darwin
#else
import Glibc
#endif
import Foundation
public enum ProviderVersionDetector {
public static func claudeVersion() -> String? {
guard let path = TTYCommandRunner.which("claude") else { return nil }
do {
let out = try TTYCommandRunner().run(
binary: path,
send: "",
options: TTYCommandRunner.Options(
timeout: 5.0,
extraArgs: ["--allowed-tools", "", "--version"],
initialDelay: 0.0,
useClaudeProbeWorkingDirectory: true)).text
let trimmed = TextParsing.stripANSICodes(out).trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
} catch {
return nil
}
}
public static func codexVersion() -> String? {
guard let path = TTYCommandRunner.which("codex") else { return nil }
let candidates = [
["--version"],
["version"],
["-v"],
]
for args in candidates {
if let version = Self.run(path: path, args: args) { return version }
}
return nil
}
public static func geminiVersion() -> String? {
let env = ProcessInfo.processInfo.environment
guard let path = BinaryLocator.resolveGeminiBinary(env: env, loginPATH: nil)
?? TTYCommandRunner.which("gemini") else { return nil }
let candidates = [
["--version"],
["-v"],
]
for args in candidates {
if let version = Self.run(path: path, args: args) { return version }
}
return nil
}
static func run(path: String, args: [String], timeout: TimeInterval = 2.0) -> String? {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: path)
proc.arguments = args
let out = Pipe()
proc.standardOutput = out
proc.standardError = Pipe()
proc.standardInput = nil
let exitSemaphore = DispatchSemaphore(value: 0)
proc.terminationHandler = { _ in
exitSemaphore.signal()
}
do {
try proc.run()
} catch {
return nil
}
let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success
if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) {
return nil
}
let data = out.fileHandleForReading.readDataToEndOfFile()
guard proc.terminationStatus == 0,
let text = String(data: data, encoding: .utf8)?
.split(whereSeparator: \.isNewline).first
else { return nil }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func forceExit(_ proc: Process, exitSemaphore: DispatchSemaphore) -> Bool {
guard proc.isRunning else { return true }
proc.terminate()
if exitSemaphore.wait(timeout: .now() + 0.5) == .success {
return true
}
guard proc.isRunning else { return true }
kill(proc.processIdentifier, SIGKILL)
return exitSemaphore.wait(timeout: .now() + 1.0) == .success
}
}