this repo has no description
at main 323 lines 12 kB view raw
1import Darwin 2import Foundation 3 4/// Executes an interactive CLI inside a pseudo-terminal and returns all captured text. 5/// Keeps it minimal so we can reuse for Codex and Claude without tmux. 6public struct TTYCommandRunner { 7 public struct Result: Sendable { 8 public let text: String 9 } 10 11 public struct Options: Sendable { 12 public var rows: UInt16 = 50 13 public var cols: UInt16 = 160 14 public var timeout: TimeInterval = 20.0 15 public var extraArgs: [String] = [] 16 17 public init(rows: UInt16 = 50, cols: UInt16 = 160, timeout: TimeInterval = 20.0, extraArgs: [String] = []) { 18 self.rows = rows 19 self.cols = cols 20 self.timeout = timeout 21 self.extraArgs = extraArgs 22 } 23 } 24 25 public enum Error: Swift.Error, LocalizedError, Sendable { 26 case binaryNotFound(String) 27 case launchFailed(String) 28 case timedOut 29 30 public var errorDescription: String? { 31 switch self { 32 case let .binaryNotFound(bin): "Binary not found on PATH: \(bin)" 33 case let .launchFailed(msg): "Failed to launch process: \(msg)" 34 case .timedOut: "PTY command timed out." 35 } 36 } 37 } 38 39 public init() {} 40 41 // swiftlint:disable function_body_length 42 public func run(binary: String, send script: String, options: Options = Options()) throws -> Result { 43 guard let resolved = Self.which(binary) else { throw Error.binaryNotFound(binary) } 44 45 var primaryFD: Int32 = -1 46 var secondaryFD: Int32 = -1 47 var term = termios() 48 var win = winsize(ws_row: options.rows, ws_col: options.cols, ws_xpixel: 0, ws_ypixel: 0) 49 guard openpty(&primaryFD, &secondaryFD, nil, &term, &win) == 0 else { 50 throw Error.launchFailed("openpty failed") 51 } 52 // Make primary side non-blocking so read loops don't hang when no data is available. 53 _ = fcntl(primaryFD, F_SETFL, O_NONBLOCK) 54 55 let primaryHandle = FileHandle(fileDescriptor: primaryFD, closeOnDealloc: true) 56 let secondaryHandle = FileHandle(fileDescriptor: secondaryFD, closeOnDealloc: true) 57 58 let proc = Process() 59 proc.executableURL = URL(fileURLWithPath: resolved) 60 proc.arguments = options.extraArgs 61 proc.standardInput = secondaryHandle 62 proc.standardOutput = secondaryHandle 63 proc.standardError = secondaryHandle 64 // Mirror RPC PATH seeding so CLIs installed via npm/nvm/fnm/bun still launch in hardened builds, 65 // but keep the callers environment (HOME, LANG, BUN_INSTALL, etc.) so the CLIs can find their 66 // auth/config files. 67 proc.environment = Self.enrichedEnvironment() 68 69 var cleanedUp = false 70 var didLaunch = false 71 var processGroup: pid_t? 72 // Always tear down the PTY child (and its process group) even if we throw early 73 // while bootstrapping the CLI (e.g. when it prompts for login/telemetry). 74 func cleanup() { 75 guard !cleanedUp else { return } 76 cleanedUp = true 77 78 if didLaunch, proc.isRunning { 79 let exitData = Data("/exit\n".utf8) 80 try? primaryHandle.write(contentsOf: exitData) 81 } 82 83 try? primaryHandle.close() 84 try? secondaryHandle.close() 85 86 guard didLaunch else { return } 87 88 if proc.isRunning { 89 proc.terminate() 90 } 91 if let pgid = processGroup { 92 kill(-pgid, SIGTERM) 93 } 94 let waitDeadline = Date().addingTimeInterval(2.0) 95 while proc.isRunning, Date() < waitDeadline { 96 usleep(100_000) 97 } 98 if proc.isRunning { 99 if let pgid = processGroup { 100 kill(-pgid, SIGKILL) 101 } 102 kill(proc.processIdentifier, SIGKILL) 103 } 104 if didLaunch { 105 proc.waitUntilExit() 106 } 107 } 108 109 // Ensure the PTY process is always torn down, even when we throw early (e.g. login prompt). 110 defer { cleanup() } 111 112 try proc.run() 113 didLaunch = true 114 115 // Isolate the child into its own process group so descendant helpers can be 116 // terminated together. If this fails (e.g. process already exec'ed), we 117 // continue and fall back to single-PID termination. 118 let pid = proc.processIdentifier 119 if setpgid(pid, pid) == 0 { 120 processGroup = pid 121 } 122 123 func send(_ text: String) throws { 124 guard let data = text.data(using: .utf8) else { return } 125 try primaryHandle.write(contentsOf: data) 126 } 127 128 let deadline = Date().addingTimeInterval(options.timeout) 129 var buffer = Data() 130 func readChunk() { 131 var tmp = [UInt8](repeating: 0, count: 8192) 132 let n = Darwin.read(primaryFD, &tmp, tmp.count) 133 if n > 0 { buffer.append(contentsOf: tmp.prefix(n)) } 134 } 135 136 func containsCodexStatus() -> Bool { 137 let markers = [ 138 "Credits:", 139 "5h limit", 140 "5-hour limit", 141 "Weekly limit", 142 ].map { Data($0.utf8) } 143 return markers.contains { buffer.contains($0) } 144 } 145 146 func respondIfCursorQuerySeen() { 147 let query = Data([0x1B, 0x5B, 0x36, 0x6E]) // ESC [ 6 n 148 if buffer.contains(query) { 149 // Pretend cursor is at 1;1, which is enough to satisfy Codex CLI's probe. 150 try? send("\u{1b}[1;1R") 151 } 152 } 153 154 func containsCodexUpdatePrompt() -> Bool { 155 let needles = [ 156 "Update available!", 157 "Run bun install -g @openai/codex", 158 "0.60.1 ->", 159 ] 160 let lower = String(data: buffer, encoding: .utf8)?.lowercased() ?? "" 161 return needles.contains { lower.contains($0.lowercased()) } 162 } 163 164 // Generic behavior (Codex /status and other commands). 165 usleep(400_000) // small boot grace 166 let delayInitialSend = script.trimmingCharacters(in: .whitespacesAndNewlines) == "/status" 167 if !delayInitialSend { 168 try send(script) 169 try send("\r") 170 usleep(150_000) 171 try send("\r") 172 try send("\u{1b}") 173 } 174 175 var skippedCodexUpdate = false 176 var sentScript = !delayInitialSend 177 var updateSkipAttempts = 0 178 var lastEnter = Date(timeIntervalSince1970: 0) 179 var scriptSentAt: Date? = sentScript ? Date() : nil 180 var resendStatusRetries = 0 181 var enterRetries = 0 182 var sawCodexStatus = false 183 184 while Date() < deadline { 185 readChunk() 186 respondIfCursorQuerySeen() 187 if !skippedCodexUpdate, containsCodexUpdatePrompt() { 188 // Prompt shows options: 1) Update now, 2) Skip, 3) Skip until next version. 189 // Users report one Down + Enter is enough; follow with an extra Enter for safety, then re-run 190 // /status. 191 try? send("\u{1b}[B") // highlight option 2 (Skip) 192 usleep(120_000) 193 try? send("\r") 194 usleep(150_000) 195 try? send("\r") // if still focused on prompt, confirm again 196 try? send("/status") 197 try? send("\r") 198 updateSkipAttempts += 1 199 if updateSkipAttempts >= 1 { 200 skippedCodexUpdate = true 201 sentScript = false // re-send /status after dismissing 202 scriptSentAt = nil 203 buffer.removeAll() 204 } 205 usleep(300_000) 206 } 207 if !sentScript, !containsCodexUpdatePrompt() || skippedCodexUpdate { 208 try? send(script) 209 try? send("\r") 210 sentScript = true 211 scriptSentAt = Date() 212 lastEnter = Date() 213 usleep(200_000) 214 continue 215 } 216 if sentScript, !containsCodexStatus() { 217 if Date().timeIntervalSince(lastEnter) >= 1.2, enterRetries < 6 { 218 try? send("\r") 219 enterRetries += 1 220 lastEnter = Date() 221 usleep(120_000) 222 continue 223 } 224 if let sentAt = scriptSentAt, 225 Date().timeIntervalSince(sentAt) >= 3.0, 226 resendStatusRetries < 2 227 { 228 try? send("/status") 229 try? send("\r") 230 resendStatusRetries += 1 231 buffer.removeAll() 232 scriptSentAt = Date() 233 lastEnter = Date() 234 usleep(220_000) 235 continue 236 } 237 } 238 if containsCodexStatus() { 239 sawCodexStatus = true 240 break 241 } 242 usleep(120_000) 243 } 244 245 if sawCodexStatus { 246 let settleDeadline = Date().addingTimeInterval(2.0) 247 while Date() < settleDeadline { 248 readChunk() 249 respondIfCursorQuerySeen() 250 usleep(100_000) 251 } 252 } 253 254 guard let text = String(data: buffer, encoding: .utf8), !text.isEmpty else { 255 throw Error.timedOut 256 } 257 258 return Result(text: text) 259 } 260 261 // swiftlint:enable function_body_length 262 263 public static func which(_ tool: String) -> String? { 264 if tool == "codex", let located = BinaryLocator.resolveCodexBinary() { return located } 265 if tool == "claude", let located = BinaryLocator.resolveClaudeBinary() { return located } 266 // First try system PATH 267 if let path = runWhich(tool) { return path } 268 // Fallback to common locations (Homebrew, local bins) 269 let home = NSHomeDirectory() 270 let candidates = [ 271 "/opt/homebrew/bin/\(tool)", 272 "/usr/local/bin/\(tool)", 273 "\(home)/.local/bin/\(tool)", 274 "\(home)/bin/\(tool)", 275 ] 276 for c in candidates where FileManager.default.isExecutableFile(atPath: c) { 277 return c 278 } 279 return nil 280 } 281 282 private static func runWhich(_ tool: String) -> String? { 283 let proc = Process() 284 proc.executableURL = URL(fileURLWithPath: "/usr/bin/which") 285 proc.arguments = [tool] 286 let pipe = Pipe() 287 proc.standardOutput = pipe 288 try? proc.run() 289 proc.waitUntilExit() 290 guard proc.terminationStatus == 0 else { return nil } 291 let data = pipe.fileHandleForReading.readDataToEndOfFile() 292 guard let path = String(data: data, encoding: .utf8)? 293 .trimmingCharacters(in: .whitespacesAndNewlines), 294 !path.isEmpty else { return nil } 295 return path 296 } 297 298 /// Expands PATH with the same defaults we use for Codex RPC, so TTY probes can find CLIs installed via Homebrew, 299 /// bun, nvm, fnm, or npm. 300 public static func enrichedPath() -> String { 301 PathBuilder.effectivePATH( 302 purposes: [.tty, .nodeTooling], 303 env: ProcessInfo.processInfo.environment) 304 } 305 306 static func enrichedEnvironment( 307 baseEnv: [String: String] = ProcessInfo.processInfo.environment, 308 home: String = NSHomeDirectory()) -> [String: String] 309 { 310 var env = baseEnv 311 env["PATH"] = PathBuilder.effectivePATH( 312 purposes: [.tty, .nodeTooling], 313 env: baseEnv, 314 home: home) 315 if env["HOME"]?.isEmpty ?? true { 316 env["HOME"] = home 317 } 318 if env["TERM"]?.isEmpty ?? true { 319 env["TERM"] = "xterm-256color" 320 } 321 return env 322 } 323}