Files
clawdbot/apps/macos/Sources/ClawdisCLI/main.swift
2025-12-07 00:55:33 +00:00

197 lines
7.1 KiB
Swift

import AsyncXPCConnection
import ClawdisIPC
import Foundation
private let serviceName = "com.steipete.clawdis.xpc"
@objc protocol ClawdisXPCProtocol {
func handle(_ data: Data, withReply reply: @escaping @Sendable (Data?, Error?) -> Void)
}
@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 {
printHelp()
exit(0)
} catch CLIError.version {
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?
while !args.isEmpty {
let arg = args.removeFirst()
switch arg {
case "--title": title = args.popFirst()
case "--body": body = args.popFirst()
case "--sound": sound = args.popFirst()
default: break
}
}
guard let t = title, let b = body else { throw CLIError.help }
return .notify(title: t, body: b, sound: sound)
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[..<eq]); let v = String(pair[pair.index(after: eq)...]); env[k] = v
}
case "--timeout": if let val = args.popFirst(), let dbl = Double(val) { timeout = dbl }
case "--needs-screen-recording": needsSR = true
default:
cmd.append(arg)
}
}
return .runShell(
command: cmd,
cwd: cwd,
env: env.isEmpty ? nil : env,
timeoutSec: timeout,
needsScreenRecording: needsSR)
case "status":
return .status
default:
throw CLIError.help
}
}
// swiftlint:enable cyclomatic_complexity
private static func printHelp() {
let usage = """
clawdis-mac — talk to the running Clawdis.app XPC service
Usage:
clawdis-mac notify --title <t> --body <b> [--sound <name>]
clawdis-mac ensure-permissions [--cap <notifications|accessibility|screenRecording|microphone|speechRecognition>] [--interactive]
clawdis-mac screenshot [--display-id <u32>] [--window-id <u32>]
clawdis-mac run [--cwd <path>] [--env KEY=VAL] [--timeout <sec>] [--needs-screen-recording] <command ...>
clawdis-mac status
clawdis-mac --help
Returns JSON to stdout:
{"ok":<bool>,"message":"...","payload":"..."}
"""
print(usage)
}
private static func printVersion() {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
let git = Bundle.main.object(forInfoDictionaryKey: "ClawdisGitCommit") as? String ?? "unknown"
let ts = Bundle.main.object(forInfoDictionaryKey: "ClawdisBuildTimestamp") as? String ?? "unknown"
print("clawdis-mac \(version) (\(build)) git:\(git) built:\(ts)")
}
private static func send(request: Request) async throws -> Response {
let conn = NSXPCConnection(machServiceName: serviceName)
let interface = NSXPCInterface(with: ClawdisXPCProtocol.self)
conn.remoteObjectInterface = interface
conn.resume()
defer { conn.invalidate() }
let data = try JSONEncoder().encode(request)
let service = AsyncXPCConnection.RemoteXPCService<ClawdisXPCProtocol>(connection: conn)
let raw: Data = try await service.withValueErrorCompletion { proxy, completion in
struct CompletionBox: @unchecked Sendable { let handler: (Data?, Error?) -> Void }
let box = CompletionBox(handler: completion)
proxy.handle(data, withReply: { data, error in box.handler(data, error) })
}
return try JSONDecoder().decode(Response.self, from: raw)
}
}
enum CLIError: Error { case help, version }
extension [String] {
mutating func popFirst() -> String? {
guard let first else { return nil }
self = Array(self.dropFirst())
return first
}
}