import ClawdisIPC import Foundation enum ShellExecutor { struct ShellResult { var stdout: String var stderr: String var exitCode: Int? var timedOut: Bool var success: Bool var errorMessage: String? } static func runDetailed( command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> ShellResult { guard !command.isEmpty else { return ShellResult( stdout: "", stderr: "", exitCode: nil, timedOut: false, success: false, errorMessage: "empty command") } let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/env") process.arguments = command if let cwd { process.currentDirectoryURL = URL(fileURLWithPath: cwd) } if let env { process.environment = env } let stdoutPipe = Pipe() let stderrPipe = Pipe() process.standardOutput = stdoutPipe process.standardError = stderrPipe do { try process.run() } catch { return ShellResult( stdout: "", stderr: "", exitCode: nil, timedOut: false, success: false, errorMessage: "failed to start: \(error.localizedDescription)") } let waitTask = Task { () -> ShellResult in process.waitUntilExit() let out = stdoutPipe.fileHandleForReading.readToEndSafely() let err = stderrPipe.fileHandleForReading.readToEndSafely() let status = Int(process.terminationStatus) return ShellResult( stdout: String(bytes: out, encoding: .utf8) ?? "", stderr: String(bytes: err, encoding: .utf8) ?? "", exitCode: status, timedOut: false, success: status == 0, errorMessage: status == 0 ? nil : "exit \(status)") } if let timeout, timeout > 0 { let nanos = UInt64(timeout * 1_000_000_000) let result = await withTaskGroup(of: ShellResult.self) { group in group.addTask { await waitTask.value } group.addTask { try? await Task.sleep(nanoseconds: nanos) if process.isRunning { process.terminate() } _ = await waitTask.value // drain pipes after termination return ShellResult( stdout: "", stderr: "", exitCode: nil, timedOut: true, success: false, errorMessage: "timeout") } let first = await group.next()! group.cancelAll() return first } return result } return await waitTask.value } static func run(command: [String], cwd: String?, env: [String: String]?, timeout: Double?) async -> Response { let result = await self.runDetailed(command: command, cwd: cwd, env: env, timeout: timeout) let combined = result.stdout.isEmpty ? result.stderr : result.stdout let payload = combined.isEmpty ? nil : Data(combined.utf8) return Response(ok: result.success, message: result.errorMessage, payload: payload) } }