Files
steipete--codexbar/Sources/CodexBarCore/Providers/ProviderVersionDetector.swift
T
2026-07-13 12:22:33 +08:00

120 lines
4.1 KiB
Swift

#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#elseif canImport(Musl)
import Musl
#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,
// `--version` alone makes Claude Code print and exit before it
// initializes its credential subsystem. Passing `--allowed-tools`
// (even empty) makes it treat the invocation as a real session and
// read the OAuth token from the macOS keychain ("Claude Code-credentials"),
// which spawns `/usr/bin/security` and triggers a keychain prompt on
// every probe when no ~/.claude/.credentials.json / env token exists.
extraArgs: ["--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,
environment: [String: String]? = nil,
mergeStandardError: Bool = false) -> String?
{
let proc = Process()
proc.executableURL = URL(fileURLWithPath: path)
proc.arguments = args
proc.environment = environment
let out = Pipe()
proc.standardOutput = out
proc.standardError = mergeStandardError ? out : FileHandle.nullDevice
proc.standardInput = nil
let outputCapture = ProcessPipeCapture(pipe: out)
outputCapture.start()
let exitSemaphore = DispatchSemaphore(value: 0)
proc.terminationHandler = { _ in
exitSemaphore.signal()
}
do {
try proc.run()
} catch {
outputCapture.stop()
return nil
}
let didExit = exitSemaphore.wait(timeout: .now() + timeout) == .success
if !didExit, !Self.forceExit(proc, exitSemaphore: exitSemaphore) {
outputCapture.stop()
return nil
}
let data = outputCapture.finishSynchronously(timeout: 0.25)
guard proc.terminationStatus == 0,
let text = ProcessPipeCapture.decodeUTF8(data)
.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
}
}