this repo has no description
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 caller’s 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}