import ClawdisIPC import Darwin import Foundation @main struct ClawdisCLI { static func main() async { do { let request = try parseCommandLine() let response = try await send(request: request) let payloadString: String? = if let payload = response.payload, let text = String( data: payload, encoding: .utf8) { text } else { nil } let output: [String: Any] = [ "ok": response.ok, "message": response.message ?? "", "payload": payloadString ?? "", ] let json = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted]) FileHandle.standardOutput.write(json) FileHandle.standardOutput.write(Data([0x0A])) exit(response.ok ? 0 : 1) } catch CLIError.help { self.printHelp() exit(0) } catch CLIError.version { self.printVersion() exit(0) } catch { fputs("clawdis-mac error: \(error)\n", stderr) exit(2) } } // swiftlint:disable cyclomatic_complexity private static func parseCommandLine() throws -> Request { var args = Array(CommandLine.arguments.dropFirst()) guard let command = args.first else { throw CLIError.help } args = Array(args.dropFirst()) switch command { case "--help", "-h", "help": throw CLIError.help case "--version", "-V", "version": throw CLIError.version case "notify": var title: String? var body: String? var sound: String? var priority: NotificationPriority? var delivery: NotificationDelivery? while !args.isEmpty { let arg = args.removeFirst() switch arg { case "--title": title = args.popFirst() case "--body": body = args.popFirst() case "--sound": sound = args.popFirst() case "--priority": if let val = args.popFirst(), let p = NotificationPriority(rawValue: val) { priority = p } case "--delivery": if let val = args.popFirst(), let d = NotificationDelivery(rawValue: val) { delivery = d } default: break } } guard let t = title, let b = body else { throw CLIError.help } return .notify(title: t, body: b, sound: sound, priority: priority, delivery: delivery) case "ensure-permissions": var caps: [Capability] = [] var interactive = false while !args.isEmpty { let arg = args.removeFirst() switch arg { case "--cap": if let val = args.popFirst(), let cap = Capability(rawValue: val) { caps.append(cap) } case "--interactive": interactive = true default: break } } if caps.isEmpty { caps = Capability.allCases } return .ensurePermissions(caps, interactive: interactive) case "screenshot": var displayID: UInt32? var windowID: UInt32? while !args.isEmpty { let arg = args.removeFirst() switch arg { case "--display-id": if let val = args.popFirst(), let num = UInt32(val) { displayID = num } case "--window-id": if let val = args.popFirst(), let num = UInt32(val) { windowID = num } default: break } } return .screenshot(displayID: displayID, windowID: windowID, format: "png") case "run": var cwd: String? var env: [String: String] = [:] var timeout: Double? var needsSR = false var cmd: [String] = [] while !args.isEmpty { let arg = args.removeFirst() switch arg { case "--cwd": cwd = args.popFirst() case "--env": if let pair = args.popFirst(), let eq = pair.firstIndex(of: "=") { let k = String(pair[.. --body [--sound ] [--priority ] [--delivery ] clawdis-mac ensure-permissions [--cap ] [--interactive] clawdis-mac screenshot [--display-id ] [--window-id ] clawdis-mac run [--cwd ] [--env KEY=VAL] [--timeout ] [--needs-screen-recording] clawdis-mac status clawdis-mac rpc-status clawdis-mac agent --message [--thinking ] [--session ] [--deliver] [--to ] clawdis-mac node list clawdis-mac node invoke --node --command [--params-json ] clawdis-mac canvas show [--session ] [--path ] [--x --y ] [--width --height ] clawdis-mac canvas hide [--session ] clawdis-mac canvas goto --path [--session ] [--x --y ] [--width --height ] clawdis-mac canvas eval --js [--session ] clawdis-mac canvas snapshot [--out ] [--session ] clawdis-mac --help Returns JSON to stdout: {"ok":,"message":"...","payload":"..."} """ print(usage) } private static func printVersion() { let info = self.loadInfo() let version = (info["CFBundleShortVersionString"] as? String) ?? self.loadPackageJSONVersion() ?? "unknown" var build = info["CFBundleVersion"] as? String ?? "" if build.isEmpty, version != "unknown" { build = version } let git = info["ClawdisGitCommit"] as? String ?? "unknown" let ts = info["ClawdisBuildTimestamp"] as? String ?? "unknown" let buildPart = build.isEmpty ? "" : " (\(build))" print("clawdis-mac \(version)\(buildPart) git:\(git) built:\(ts)") } private static func loadInfo() -> [String: Any] { if let dict = Bundle.main.infoDictionary, !dict.isEmpty { return dict } guard let exeURL = self.resolveExecutableURL() else { return [:] } var dir = exeURL.deletingLastPathComponent() for _ in 0..<10 { let candidate = dir.appendingPathComponent("Info.plist") if let dict = self.loadPlistDictionary(at: candidate) { return dict } let parent = dir.deletingLastPathComponent() if parent.path == dir.path { break } dir = parent } return [:] } private static func loadPlistDictionary(at url: URL) -> [String: Any]? { guard let data = try? Data(contentsOf: url) else { return nil } return try? PropertyListSerialization .propertyList(from: data, options: [], format: nil) as? [String: Any] } private static func resolveExecutableURL() -> URL? { var size = UInt32(PATH_MAX) var buffer = [CChar](repeating: 0, count: Int(size)) let result = buffer.withUnsafeMutableBufferPointer { ptr in _NSGetExecutablePath(ptr.baseAddress, &size) } if result != 0 { buffer = [CChar](repeating: 0, count: Int(size)) let result2 = buffer.withUnsafeMutableBufferPointer { ptr in _NSGetExecutablePath(ptr.baseAddress, &size) } guard result2 == 0 else { return nil } } let nulIndex = buffer.firstIndex(of: 0) ?? buffer.count let bytes = buffer.prefix(nulIndex).map { UInt8(bitPattern: $0) } let path = String(decoding: bytes, as: UTF8.self) return URL(fileURLWithPath: path).resolvingSymlinksInPath() } private static func loadPackageJSONVersion() -> String? { guard let exeURL = self.resolveExecutableURL() else { return nil } var dir = exeURL.deletingLastPathComponent() for _ in 0..<12 { let candidate = dir.appendingPathComponent("package.json") if let version = self.loadPackageJSONVersion(at: candidate) { return version } let parent = dir.deletingLastPathComponent() if parent.path == dir.path { break } dir = parent } return nil } private static func loadPackageJSONVersion(at url: URL) -> String? { guard let data = try? Data(contentsOf: url) else { return nil } guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } guard obj["name"] as? String == "clawdis" else { return nil } return obj["version"] as? String } private static func send(request: Request) async throws -> Response { try await self.ensureAppRunning() let timeout = self.rpcTimeoutSeconds(for: request) return try await self.sendViaSocket(request: request, timeoutSeconds: timeout) } /// Attempt a direct UNIX socket call; falls back to XPC if unavailable. private static func sendViaSocket(request: Request, timeoutSeconds: TimeInterval) async throws -> Response { let path = controlSocketPath let deadline = Date().addingTimeInterval(timeoutSeconds) let fd = socket(AF_UNIX, SOCK_STREAM, 0) guard fd >= 0 else { throw POSIXError(.ECONNREFUSED) } defer { close(fd) } var noSigPipe: Int32 = 1 _ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSigPipe, socklen_t(MemoryLayout.size(ofValue: noSigPipe))) let flags = fcntl(fd, F_GETFL) if flags != -1 { _ = fcntl(fd, F_SETFL, flags | O_NONBLOCK) } var addr = sockaddr_un() addr.sun_family = sa_family_t(AF_UNIX) let capacity = MemoryLayout.size(ofValue: addr.sun_path) let copied = path.withCString { cstr -> Int in strlcpy(&addr.sun_path.0, cstr, capacity) } guard copied < capacity else { throw POSIXError(.ENAMETOOLONG) } addr.sun_len = UInt8(MemoryLayout.size(ofValue: addr)) let len = socklen_t(MemoryLayout.size(ofValue: addr)) let result = withUnsafePointer(to: &addr) { ptr -> Int32 in ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in connect(fd, sockPtr, len) } } if result != 0 { let err = errno if err == EINPROGRESS { try self.waitForSocket( fd: fd, events: Int16(POLLOUT), until: deadline, timeoutSeconds: timeoutSeconds) var soError: Int32 = 0 var soLen = socklen_t(MemoryLayout.size(ofValue: soError)) _ = getsockopt(fd, SOL_SOCKET, SO_ERROR, &soError, &soLen) if soError != 0 { throw POSIXError(POSIXErrorCode(rawValue: soError) ?? .ECONNREFUSED) } } else { throw POSIXError(POSIXErrorCode(rawValue: err) ?? .ECONNREFUSED) } } let payload = try JSONEncoder().encode(request) try payload.withUnsafeBytes { buf in guard let base = buf.baseAddress else { return } var written = 0 while written < payload.count { try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds) let n = write(fd, base.advanced(by: written), payload.count - written) if n > 0 { written += n continue } if n == -1, errno == EINTR { continue } if n == -1, errno == EAGAIN { try self.waitForSocket( fd: fd, events: Int16(POLLOUT), until: deadline, timeoutSeconds: timeoutSeconds) continue } throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } } shutdown(fd, SHUT_WR) var data = Data() var buffer = [UInt8](repeating: 0, count: 8192) let bufSize = buffer.count while true { try self.ensureDeadline(deadline, timeoutSeconds: timeoutSeconds) try self.waitForSocket( fd: fd, events: Int16(POLLIN), until: deadline, timeoutSeconds: timeoutSeconds) let n = buffer.withUnsafeMutableBytes { read(fd, $0.baseAddress!, bufSize) } if n > 0 { data.append(buffer, count: n); continue } if n == 0 { break } if n == -1, errno == EINTR { continue } if n == -1, errno == EAGAIN { continue } throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } guard !data.isEmpty else { throw POSIXError(.ECONNRESET) } return try JSONDecoder().decode(Response.self, from: data) } private static func rpcTimeoutSeconds(for request: Request) -> TimeInterval { switch request { case let .runShell(_, _, _, timeoutSec, _): // Allow longer for commands; still cap overall to a sane bound. return min(300, max(10, (timeoutSec ?? 10) + 2)) default: // Fail-fast so callers (incl. SSH tool calls) don't hang forever. return 10 } } private static func ensureDeadline(_ deadline: Date, timeoutSeconds: TimeInterval) throws { if Date() >= deadline { throw CLITimeoutError(seconds: timeoutSeconds) } } private static func waitForSocket( fd: Int32, events: Int16, until deadline: Date, timeoutSeconds: TimeInterval) throws { while true { let remaining = deadline.timeIntervalSinceNow if remaining <= 0 { throw CLITimeoutError(seconds: timeoutSeconds) } var pfd = pollfd(fd: fd, events: events, revents: 0) let ms = Int32(max(1, min(remaining, 0.5) * 1000)) // small slices so we enforce total timeout let n = poll(&pfd, 1, ms) if n > 0 { return } if n == 0 { continue } if errno == EINTR { continue } throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) } } private static func ensureAppRunning() async throws { let appURL = URL(fileURLWithPath: CommandLine.arguments.first ?? "") .resolvingSymlinksInPath() .deletingLastPathComponent() // MacOS .deletingLastPathComponent() // Contents let proc = Process() proc.launchPath = "/usr/bin/open" proc.arguments = ["-n", appURL.path] proc.standardOutput = Pipe() proc.standardError = Pipe() try proc.run() try? await Task.sleep(nanoseconds: 100_000_000) } } enum CLIError: Error { case help, version } struct CLITimeoutError: Error, CustomStringConvertible { let seconds: TimeInterval var description: String { let rounded = Int(max(1, seconds.rounded(.toNearestOrEven))) return "timed out after \(rounded)s" } } extension [String] { mutating func popFirst() -> String? { guard let first else { return nil } self = Array(self.dropFirst()) return first } }