diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 1a2c5e663..99ae0f991 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -12,8 +12,7 @@ let package = Package( .library(name: "ClawdbotIPC", targets: ["ClawdbotIPC"]), .library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]), - .executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]), - .executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]), + .executable(name: "clawdbot-mac", targets: ["ClawdbotMacCLI"]), ], dependencies: [ .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), @@ -67,21 +66,13 @@ let package = Package( .enableUpcomingFeature("StrictConcurrency"), ]), .executableTarget( - name: "ClawdbotDiscoveryCLI", + name: "ClawdbotMacCLI", dependencies: [ "ClawdbotDiscovery", - ], - path: "Sources/ClawdbotDiscoveryCLI", - swiftSettings: [ - .enableUpcomingFeature("StrictConcurrency"), - ]), - .executableTarget( - name: "ClawdbotWizardCLI", - dependencies: [ .product(name: "ClawdbotKit", package: "ClawdbotKit"), .product(name: "ClawdbotProtocol", package: "ClawdbotKit"), ], - path: "Sources/ClawdbotWizardCLI", + path: "Sources/ClawdbotMacCLI", swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), diff --git a/apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift b/apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift deleted file mode 100644 index d5fc5789c..000000000 --- a/apps/macos/Sources/ClawdbotDiscoveryCLI/main.swift +++ /dev/null @@ -1,150 +0,0 @@ -import ClawdbotDiscovery -import Foundation - -struct DiscoveryOptions { - var timeoutMs: Int = 2000 - var json: Bool = false - var includeLocal: Bool = false - var help: Bool = false - - static func parse(_ args: [String]) -> DiscoveryOptions { - var opts = DiscoveryOptions() - var i = 0 - while i < args.count { - let arg = args[i] - switch arg { - case "-h", "--help": - opts.help = true - case "--json": - opts.json = true - case "--include-local": - opts.includeLocal = true - case "--timeout": - let next = (i + 1 < args.count) ? args[i + 1] : nil - if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) { - opts.timeoutMs = max(100, parsed) - i += 1 - } - default: - break - } - i += 1 - } - return opts - } -} - -struct DiscoveryOutput: Encodable { - struct Gateway: Encodable { - var displayName: String - var lanHost: String? - var tailnetDns: String? - var sshPort: Int - var gatewayPort: Int? - var cliPath: String? - var stableID: String - var debugID: String - var isLocal: Bool - } - - var status: String - var timeoutMs: Int - var includeLocal: Bool - var count: Int - var gateways: [Gateway] -} - -@main -struct ClawdbotDiscoveryCLI { - static func main() async { - let opts = DiscoveryOptions.parse(Array(CommandLine.arguments.dropFirst())) - if opts.help { - print(""" - clawdbot-mac-discovery - - Usage: - clawdbot-mac-discovery [--timeout ] [--json] [--include-local] - - Options: - --timeout Discovery window in milliseconds (default: 2000) - --json Emit JSON - --include-local Include gateways considered local - -h, --help Show help - """) - return - } - - let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName - let model = GatewayDiscoveryModel( - localDisplayName: displayName, - filterLocalGateways: !opts.includeLocal) - - await MainActor.run { - model.start() - } - - let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000 - try? await Task.sleep(nanoseconds: nanos) - - let gateways = await MainActor.run { model.gateways } - let status = await MainActor.run { model.statusText } - - await MainActor.run { - model.stop() - } - - if opts.json { - let payload = DiscoveryOutput( - status: status, - timeoutMs: opts.timeoutMs, - includeLocal: opts.includeLocal, - count: gateways.count, - gateways: gateways.map { - DiscoveryOutput.Gateway( - displayName: $0.displayName, - lanHost: $0.lanHost, - tailnetDns: $0.tailnetDns, - sshPort: $0.sshPort, - gatewayPort: $0.gatewayPort, - cliPath: $0.cliPath, - stableID: $0.stableID, - debugID: $0.debugID, - isLocal: $0.isLocal) - }) - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - if let data = try? encoder.encode(payload), - let json = String(data: data, encoding: .utf8) - { - print(json) - } else { - print("{\"error\":\"failed to encode JSON\"}") - } - return - } - - print("Gateway Discovery (macOS NWBrowser)") - print("Status: \(status)") - print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")") - if gateways.isEmpty { return } - - for gateway in gateways { - let hosts = [gateway.tailnetDns, gateway.lanHost] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .joined(separator: ", ") - print("- \(gateway.displayName)") - print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)") - print(" ssh: \(gateway.sshPort)") - if let port = gateway.gatewayPort { - print(" gatewayPort: \(port)") - } - if let cliPath = gateway.cliPath { - print(" cliPath: \(cliPath)") - } - print(" isLocal: \(gateway.isLocal)") - print(" stableID: \(gateway.stableID)") - print(" debugID: \(gateway.debugID)") - } - } -} diff --git a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift new file mode 100644 index 000000000..08e8cdde6 --- /dev/null +++ b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift @@ -0,0 +1,306 @@ +import ClawdbotKit +import ClawdbotProtocol +import Foundation + +struct ConnectOptions { + var url: String? + var token: String? + var password: String? + var mode: String? + var timeoutMs: Int = 15_000 + var json: Bool = false + var probe: Bool = false + var clientId: String = "clawdbot-macos" + var clientMode: String = "ui" + var displayName: String? + var role: String = "operator" + var scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"] + var help: Bool = false + + static func parse(_ args: [String]) -> ConnectOptions { + var opts = ConnectOptions() + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "-h", "--help": + opts.help = true + case "--json": + opts.json = true + case "--probe": + opts.probe = true + case "--url": + opts.url = self.nextValue(args, index: &i) + case "--token": + opts.token = self.nextValue(args, index: &i) + case "--password": + opts.password = self.nextValue(args, index: &i) + case "--mode": + if let value = self.nextValue(args, index: &i) { + opts.mode = value + } + case "--timeout": + if let raw = self.nextValue(args, index: &i), + let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + { + opts.timeoutMs = max(250, parsed) + } + case "--client-id": + if let value = self.nextValue(args, index: &i) { + opts.clientId = value + } + case "--client-mode": + if let value = self.nextValue(args, index: &i) { + opts.clientMode = value + } + case "--display-name": + opts.displayName = self.nextValue(args, index: &i) + case "--role": + if let value = self.nextValue(args, index: &i) { + opts.role = value + } + case "--scopes": + if let value = self.nextValue(args, index: &i) { + opts.scopes = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + default: + break + } + i += 1 + } + return opts + } + + private static func nextValue(_ args: [String], index: inout Int) -> String? { + guard index + 1 < args.count else { return nil } + index += 1 + return args[index].trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +struct ConnectOutput: Encodable { + var status: String + var url: String + var mode: String + var role: String + var clientId: String + var clientMode: String + var scopes: [String] + var snapshot: HelloOk? + var health: ProtoAnyCodable? + var error: String? +} + +actor SnapshotStore { + private var value: HelloOk? + + func set(_ snapshot: HelloOk) { + self.value = snapshot + } + + func get() -> HelloOk? { + self.value + } +} + +func runConnect(_ args: [String]) async { + let opts = ConnectOptions.parse(args) + if opts.help { + print(""" + clawdbot-mac connect + + Usage: + clawdbot-mac connect [--url ] [--token ] [--password ] + [--mode ] [--timeout ] [--probe] [--json] + [--client-id ] [--client-mode ] [--display-name ] + [--role ] [--scopes ] + + Options: + --url Gateway WebSocket URL (overrides config) + --token Gateway token (if required) + --password Gateway password (if required) + --mode Resolve from config: local|remote (default: config or local) + --timeout Request timeout (default: 15000) + --probe Force a fresh health probe + --json Emit JSON + --client-id Override client id (default: clawdbot-macos) + --client-mode Override client mode (default: ui) + --display-name Override display name + --role Override role (default: operator) + --scopes Override scopes list + -h, --help Show help + """) + return + } + + let config = loadGatewayConfig() + do { + let endpoint = try resolveGatewayEndpoint(opts: opts, config: config) + let displayName = opts.displayName ?? Host.current().localizedName ?? "Clawdbot macOS Debug CLI" + let connectOptions = GatewayConnectOptions( + role: opts.role, + scopes: opts.scopes, + caps: [], + commands: [], + permissions: [:], + clientId: opts.clientId, + clientMode: opts.clientMode, + clientDisplayName: displayName) + + let snapshotStore = SnapshotStore() + let channel = GatewayChannelActor( + url: endpoint.url, + token: endpoint.token, + password: endpoint.password, + pushHandler: { push in + if case let .snapshot(ok) = push { + await snapshotStore.set(ok) + } + }, + connectOptions: connectOptions) + + let params: [String: KitAnyCodable]? = opts.probe ? ["probe": KitAnyCodable(true)] : nil + let data = try await channel.request( + method: "health", + params: params, + timeoutMs: Double(opts.timeoutMs)) + let health = try? JSONDecoder().decode(ProtoAnyCodable.self, from: data) + let snapshot = await snapshotStore.get() + await channel.shutdown() + + let output = ConnectOutput( + status: "ok", + url: endpoint.url.absoluteString, + mode: endpoint.mode, + role: opts.role, + clientId: opts.clientId, + clientMode: opts.clientMode, + scopes: opts.scopes, + snapshot: snapshot, + health: health, + error: nil) + printConnectOutput(output, json: opts.json) + } catch { + let endpoint = bestEffortEndpoint(opts: opts, config: config) + let fallbackMode = (opts.mode ?? config.mode ?? "local").lowercased() + let output = ConnectOutput( + status: "error", + url: endpoint?.url.absoluteString ?? "unknown", + mode: endpoint?.mode ?? fallbackMode, + role: opts.role, + clientId: opts.clientId, + clientMode: opts.clientMode, + scopes: opts.scopes, + snapshot: nil, + health: nil, + error: error.localizedDescription) + printConnectOutput(output, json: opts.json) + exit(1) + } +} + +private func printConnectOutput(_ output: ConnectOutput, json: Bool) { + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(output), + let text = String(data: data, encoding: .utf8) + { + print(text) + } else { + print("{\"error\":\"failed to encode JSON\"}") + } + return + } + + print("Clawdbot macOS Gateway Connect") + print("Status: \(output.status)") + print("URL: \(output.url)") + print("Mode: \(output.mode)") + print("Client: \(output.clientId) (\(output.clientMode))") + print("Role: \(output.role)") + print("Scopes: \(output.scopes.joined(separator: ", "))") + if let snapshot = output.snapshot { + print("Protocol: \(snapshot._protocol)") + if let version = snapshot.server["version"]?.value as? String { + print("Server: \(version)") + } + } + if let health = output.health, + let ok = (health.value as? [String: ProtoAnyCodable])?["ok"]?.value as? Bool + { + print("Health: \(ok ? "ok" : "error")") + } else if output.health != nil { + print("Health: received") + } + if let error = output.error { + print("Error: \(error)") + } +} + +private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) throws -> GatewayEndpoint { + let resolvedMode = (opts.mode ?? config.mode ?? "local").lowercased() + if let raw = opts.url, !raw.isEmpty { + guard let url = URL(string: raw) else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: resolvedMode, config: config), + password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), + mode: resolvedMode) + } + + if resolvedMode == "remote" { + guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), + !raw.isEmpty else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url is missing"]) + } + guard let url = URL(string: raw) else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: \(raw)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: resolvedMode, config: config), + password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), + mode: resolvedMode) + } + + let port = config.port ?? 18789 + let host = "127.0.0.1" + guard let url = URL(string: "ws://\(host):\(port)") else { + throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, mode: resolvedMode, config: config), + password: resolvedPassword(opts: opts, mode: resolvedMode, config: config), + mode: resolvedMode) +} + +private func bestEffortEndpoint(opts: ConnectOptions, config: GatewayConfig) -> GatewayEndpoint? { + return try? resolveGatewayEndpoint(opts: opts, config: config) +} + +private func resolvedToken(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { + if let token = opts.token, !token.isEmpty { return token } + if let token = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_TOKEN"], !token.isEmpty { + return token + } + if mode == "remote" { + return config.remoteToken + } + return config.token +} + +private func resolvedPassword(opts: ConnectOptions, mode: String, config: GatewayConfig) -> String? { + if let password = opts.password, !password.isEmpty { return password } + if let password = ProcessInfo.processInfo.environment["CLAWDBOT_GATEWAY_PASSWORD"], !password.isEmpty { + return password + } + if mode == "remote" { + return config.remotePassword + } + return config.password +} diff --git a/apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift b/apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift new file mode 100644 index 000000000..12fe1ea37 --- /dev/null +++ b/apps/macos/Sources/ClawdbotMacCLI/DiscoverCommand.swift @@ -0,0 +1,149 @@ +import ClawdbotDiscovery +import Foundation + +struct DiscoveryOptions { + var timeoutMs: Int = 2000 + var json: Bool = false + var includeLocal: Bool = false + var help: Bool = false + + static func parse(_ args: [String]) -> DiscoveryOptions { + var opts = DiscoveryOptions() + var i = 0 + while i < args.count { + let arg = args[i] + switch arg { + case "-h", "--help": + opts.help = true + case "--json": + opts.json = true + case "--include-local": + opts.includeLocal = true + case "--timeout": + let next = (i + 1 < args.count) ? args[i + 1] : nil + if let next, let parsed = Int(next.trimmingCharacters(in: .whitespacesAndNewlines)) { + opts.timeoutMs = max(100, parsed) + i += 1 + } + default: + break + } + i += 1 + } + return opts + } +} + +struct DiscoveryOutput: Encodable { + struct Gateway: Encodable { + var displayName: String + var lanHost: String? + var tailnetDns: String? + var sshPort: Int + var gatewayPort: Int? + var cliPath: String? + var stableID: String + var debugID: String + var isLocal: Bool + } + + var status: String + var timeoutMs: Int + var includeLocal: Bool + var count: Int + var gateways: [Gateway] +} + +func runDiscover(_ args: [String]) async { + let opts = DiscoveryOptions.parse(args) + if opts.help { + print(""" + clawdbot-mac discover + + Usage: + clawdbot-mac discover [--timeout ] [--json] [--include-local] + + Options: + --timeout Discovery window in milliseconds (default: 2000) + --json Emit JSON + --include-local Include gateways considered local + -h, --help Show help + """) + return + } + + let displayName = Host.current().localizedName ?? ProcessInfo.processInfo.hostName + let model = await MainActor.run { + GatewayDiscoveryModel( + localDisplayName: displayName, + filterLocalGateways: !opts.includeLocal) + } + + await MainActor.run { + model.start() + } + + let nanos = UInt64(max(100, opts.timeoutMs)) * 1_000_000 + try? await Task.sleep(nanoseconds: nanos) + + let gateways = await MainActor.run { model.gateways } + let status = await MainActor.run { model.statusText } + + await MainActor.run { + model.stop() + } + + if opts.json { + let payload = DiscoveryOutput( + status: status, + timeoutMs: opts.timeoutMs, + includeLocal: opts.includeLocal, + count: gateways.count, + gateways: gateways.map { + DiscoveryOutput.Gateway( + displayName: $0.displayName, + lanHost: $0.lanHost, + tailnetDns: $0.tailnetDns, + sshPort: $0.sshPort, + gatewayPort: $0.gatewayPort, + cliPath: $0.cliPath, + stableID: $0.stableID, + debugID: $0.debugID, + isLocal: $0.isLocal) + }) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(payload), + let json = String(data: data, encoding: .utf8) + { + print(json) + } else { + print("{\"error\":\"failed to encode JSON\"}") + } + return + } + + print("Gateway Discovery (macOS NWBrowser)") + print("Status: \(status)") + print("Found \(gateways.count) gateway(s)\(opts.includeLocal ? "" : " (local filtered)")") + if gateways.isEmpty { return } + + for gateway in gateways { + let hosts = [gateway.tailnetDns, gateway.lanHost] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ", ") + print("- \(gateway.displayName)") + print(" hosts: \(hosts.isEmpty ? "(none)" : hosts)") + print(" ssh: \(gateway.sshPort)") + if let port = gateway.gatewayPort { + print(" gatewayPort: \(port)") + } + if let cliPath = gateway.cliPath { + print(" cliPath: \(cliPath)") + } + print(" isLocal: \(gateway.isLocal)") + print(" stableID: \(gateway.stableID)") + print(" debugID: \(gateway.debugID)") + } +} diff --git a/apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift b/apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift new file mode 100644 index 000000000..58f4501ae --- /dev/null +++ b/apps/macos/Sources/ClawdbotMacCLI/EntryPoint.swift @@ -0,0 +1,56 @@ +import Foundation + +private struct RootCommand { + var name: String + var args: [String] +} + +@main +struct ClawdbotMacCLI { + static func main() async { + let args = Array(CommandLine.arguments.dropFirst()) + let command = parseRootCommand(args) + switch command?.name { + case nil: + printUsage() + case "-h", "--help", "help": + printUsage() + case "connect": + await runConnect(command?.args ?? []) + case "discover": + await runDiscover(command?.args ?? []) + case "wizard": + await runWizardCommand(command?.args ?? []) + default: + fputs("clawdbot-mac: unknown command\n", stderr) + printUsage() + exit(1) + } + } +} + +private func parseRootCommand(_ args: [String]) -> RootCommand? { + guard let first = args.first else { return nil } + return RootCommand(name: first, args: Array(args.dropFirst())) +} + +private func printUsage() { + print(""" + clawdbot-mac + + Usage: + clawdbot-mac connect [--url ] [--token ] [--password ] + [--mode ] [--timeout ] [--probe] [--json] + [--client-id ] [--client-mode ] [--display-name ] + [--role ] [--scopes ] + clawdbot-mac discover [--timeout ] [--json] [--include-local] + clawdbot-mac wizard [--url ] [--token ] [--password ] + [--mode ] [--workspace ] [--json] + + Examples: + clawdbot-mac connect + clawdbot-mac connect --url ws://127.0.0.1:18789 --json + clawdbot-mac discover --timeout 3000 --json + clawdbot-mac wizard --mode local + """) +} diff --git a/apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift b/apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift new file mode 100644 index 000000000..d39572396 --- /dev/null +++ b/apps/macos/Sources/ClawdbotMacCLI/GatewayConfig.swift @@ -0,0 +1,60 @@ +import Foundation + +struct GatewayConfig { + var mode: String? + var bind: String? + var port: Int? + var remoteUrl: String? + var token: String? + var password: String? + var remoteToken: String? + var remotePassword: String? +} + +struct GatewayEndpoint { + let url: URL + let token: String? + let password: String? + let mode: String +} + +func loadGatewayConfig() -> GatewayConfig { + let url = FileManager().homeDirectoryForCurrentUser + .appendingPathComponent(".clawdbot") + .appendingPathComponent("clawdbot.json") + guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return GatewayConfig() + } + + var cfg = GatewayConfig() + if let gateway = json["gateway"] as? [String: Any] { + cfg.mode = gateway["mode"] as? String + cfg.bind = gateway["bind"] as? String + cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"]) + + if let auth = gateway["auth"] as? [String: Any] { + cfg.token = auth["token"] as? String + cfg.password = auth["password"] as? String + } + if let remote = gateway["remote"] as? [String: Any] { + cfg.remoteUrl = remote["url"] as? String + cfg.remoteToken = remote["token"] as? String + cfg.remotePassword = remote["password"] as? String + } + } + return cfg +} + +func parseInt(_ value: Any?) -> Int? { + switch value { + case let number as Int: + number + case let number as Double: + Int(number) + case let raw as String: + Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + default: + nil + } +} diff --git a/apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift b/apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift new file mode 100644 index 000000000..191b8976b --- /dev/null +++ b/apps/macos/Sources/ClawdbotMacCLI/TypeAliases.swift @@ -0,0 +1,5 @@ +import ClawdbotKit +import ClawdbotProtocol + +typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable +typealias KitAnyCodable = ClawdbotKit.AnyCodable diff --git a/apps/macos/Sources/ClawdbotWizardCLI/main.swift b/apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift similarity index 84% rename from apps/macos/Sources/ClawdbotWizardCLI/main.swift rename to apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift index 54ddac866..6b7e342e5 100644 --- a/apps/macos/Sources/ClawdbotWizardCLI/main.swift +++ b/apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift @@ -3,8 +3,6 @@ import ClawdbotProtocol import Darwin import Foundation -private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable - struct WizardCliOptions { var url: String? var token: String? @@ -51,17 +49,6 @@ struct WizardCliOptions { } } -struct GatewayConfig { - var mode: String? - var bind: String? - var port: Int? - var remoteUrl: String? - var token: String? - var password: String? - var remoteToken: String? - var remotePassword: String? -} - enum WizardCliError: Error, CustomStringConvertible { case invalidUrl(String) case missingRemoteUrl @@ -80,68 +67,56 @@ enum WizardCliError: Error, CustomStringConvertible { } } -@main -struct ClawdbotWizardCLI { - static func main() async { - let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst())) - if opts.help { - printUsage() - return - } +func runWizardCommand(_ args: [String]) async { + let opts = WizardCliOptions.parse(args) + if opts.help { + print(""" + clawdbot-mac wizard - let config = loadGatewayConfig() - do { - guard isatty(STDIN_FILENO) != 0 else { - throw WizardCliError.gatewayError("Wizard requires an interactive TTY.") - } - let endpoint = try resolveGatewayEndpoint(opts: opts, config: config) - let client = GatewayWizardClient( - url: endpoint.url, - token: endpoint.token, - password: endpoint.password, - json: opts.json) - try await client.connect() - defer { Task { await client.close() } } - try await runWizard(client: client, opts: opts) - } catch { - fputs("wizard: \(error)\n", stderr) - exit(1) + Usage: + clawdbot-mac wizard [--url ] [--token ] [--password ] + [--mode ] [--workspace ] [--json] + + Options: + --url Gateway WebSocket URL (overrides config) + --token Gateway token (if required) + --password Gateway password (if required) + --mode Wizard mode (local|remote). Default: local + --workspace Wizard workspace override + --json Print raw wizard responses + -h, --help Show help + """) + return + } + + let config = loadGatewayConfig() + do { + guard isatty(STDIN_FILENO) != 0 else { + throw WizardCliError.gatewayError("Wizard requires an interactive TTY.") } + let endpoint = try resolveWizardGatewayEndpoint(opts: opts, config: config) + let client = GatewayWizardClient( + url: endpoint.url, + token: endpoint.token, + password: endpoint.password, + json: opts.json) + try await client.connect() + defer { Task { await client.close() } } + try await runWizard(client: client, opts: opts) + } catch { + fputs("wizard: \(error)\n", stderr) + exit(1) } } -private struct GatewayEndpoint { - let url: URL - let token: String? - let password: String? -} - -private func printUsage() { - print(""" - clawdbot-mac-wizard - - Usage: - clawdbot-mac-wizard [--url ] [--token ] [--password ] - [--mode ] [--workspace ] [--json] - - Options: - --url Gateway WebSocket URL (overrides config) - --token Gateway token (if required) - --password Gateway password (if required) - --mode Wizard mode (local|remote). Default: local - --workspace Wizard workspace override - --json Print raw wizard responses - -h, --help Show help - """) -} - -private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint { +private func resolveWizardGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfig) throws -> GatewayEndpoint { if let raw = opts.url, !raw.isEmpty { guard let url = URL(string: raw) else { throw WizardCliError.invalidUrl(raw) } return GatewayEndpoint( url: url, token: resolvedToken(opts: opts, config: config), - password: resolvedPassword(opts: opts, config: config)) + password: resolvedPassword(opts: opts, config: config), + mode: (config.mode ?? "local").lowercased()) } let mode = (config.mode ?? "local").lowercased() @@ -153,7 +128,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi return GatewayEndpoint( url: url, token: resolvedToken(opts: opts, config: config), - password: resolvedPassword(opts: opts, config: config)) + password: resolvedPassword(opts: opts, config: config), + mode: mode) } let port = config.port ?? 18789 @@ -164,7 +140,8 @@ private func resolveGatewayEndpoint(opts: WizardCliOptions, config: GatewayConfi return GatewayEndpoint( url: url, token: resolvedToken(opts: opts, config: config), - password: resolvedPassword(opts: opts, config: config)) + password: resolvedPassword(opts: opts, config: config), + mode: mode) } private func resolvedToken(opts: WizardCliOptions, config: GatewayConfig) -> String? { @@ -189,47 +166,6 @@ private func resolvedPassword(opts: WizardCliOptions, config: GatewayConfig) -> return config.password } -private func loadGatewayConfig() -> GatewayConfig { - let url = FileManager().homeDirectoryForCurrentUser - .appendingPathComponent(".clawdbot") - .appendingPathComponent("clawdbot.json") - guard let data = try? Data(contentsOf: url) else { return GatewayConfig() } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return GatewayConfig() - } - - var cfg = GatewayConfig() - if let gateway = json["gateway"] as? [String: Any] { - cfg.mode = gateway["mode"] as? String - cfg.bind = gateway["bind"] as? String - cfg.port = gateway["port"] as? Int ?? parseInt(gateway["port"]) - - if let auth = gateway["auth"] as? [String: Any] { - cfg.token = auth["token"] as? String - cfg.password = auth["password"] as? String - } - if let remote = gateway["remote"] as? [String: Any] { - cfg.remoteUrl = remote["url"] as? String - cfg.remoteToken = remote["token"] as? String - cfg.remotePassword = remote["password"] as? String - } - } - return cfg -} - -private func parseInt(_ value: Any?) -> Int? { - switch value { - case let number as Int: - number - case let number as Double: - Int(number) - case let raw as String: - Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) - default: - nil - } -} - actor GatewayWizardClient { private enum ConnectChallengeError: Error { case timeout @@ -411,7 +347,7 @@ actor GatewayWizardClient { operation: { while true { let message = try await task.receive() - let frame = try decodeFrame(message) + let frame = try await self.decodeFrame(message) if case let .event(evt) = frame, evt.event == "connect.challenge" { if let payload = evt.payload?.value as? [String: ProtoAnyCodable], let nonce = payload["nonce"]?.value as? String diff --git a/docs/platforms/mac/xpc.md b/docs/platforms/mac/xpc.md index dab94c2de..5aa22b156 100644 --- a/docs/platforms/mac/xpc.md +++ b/docs/platforms/mac/xpc.md @@ -5,7 +5,7 @@ read_when: --- # Clawdbot macOS IPC architecture -**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge. +**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. A `clawdbot-mac` debug CLI exists for discovery/connect checks; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge. ## Goals - Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript). diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 040a28989..561e09189 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -140,19 +140,27 @@ Safety: - `swift run Clawdbot` (or Xcode) - Package app: `scripts/package-mac-app.sh` -## Debug gateway discovery (macOS CLI) +## Debug gateway connectivity (macOS CLI) -Use the debug CLI to exercise the same Bonjour + wide‑area discovery code that the -macOS app uses, without launching the app. +Use the debug CLI to exercise the same Gateway WebSocket handshake and discovery +logic that the macOS app uses, without launching the app. ```bash cd apps/macos -swift run clawdbot-mac-discovery --timeout 3000 --json +swift run clawdbot-mac connect --json +swift run clawdbot-mac discover --timeout 3000 --json ``` -Options: +Connect options: +- `--url `: override config +- `--mode `: resolve from config (default: config or local) +- `--probe`: force a fresh health probe +- `--timeout `: request timeout (default: `15000`) +- `--json`: structured output for diffing + +Discovery options: - `--include-local`: include gateways that would be filtered as “local” -- `--timeout `: overall discovery window (default `2000`) +- `--timeout `: overall discovery window (default: `2000`) - `--json`: structured output for diffing Tip: compare against `clawdbot gateway discover --json` to see whether the