From 4c5f78ca01b7b1a380160eb99a9da794a46022bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 12 Jan 2026 07:40:59 +0000 Subject: [PATCH] feat(macos): add wizard debug CLI --- CHANGELOG.md | 2 + apps/macos/Package.swift | 10 + .../ClawdbotProtocol/WizardHelpers.swift | 106 ++++ .../Sources/ClawdbotWizardCLI/main.swift | 543 ++++++++++++++++++ 4 files changed, 661 insertions(+) create mode 100644 apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift create mode 100644 apps/macos/Sources/ClawdbotWizardCLI/main.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index aeff73191..1e7c41256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Agents: automatic pre-compaction memory flush turn to store durable memories before compaction. ### Changes +- macOS: add wizard debug CLI and share wizard parsing helpers. - CLI/Onboarding: simplify MiniMax auth choice to a single M2.1 option. - CLI: configure section selection now loops until Continue. - Docs: explain MiniMax vs MiniMax Lightning (speed vs cost) and restore LM Studio example. @@ -52,6 +53,7 @@ - Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm). ### Fixes +- macOS: start + await local gateway before onboarding wizard begins. - Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias). - Agents/Browser: cap Playwright AI snapshots for tool calls (maxChars); CLI snapshots remain full. (#763) — thanks @thesash. - Models: normalize Gemini 3 Pro/Flash IDs to preview names for live model lookups. (#769) — thanks @steipete. diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 3973bff61..36928c71c 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -13,6 +13,7 @@ let package = Package( .library(name: "ClawdbotDiscovery", targets: ["ClawdbotDiscovery"]), .executable(name: "Clawdbot", targets: ["Clawdbot"]), .executable(name: "clawdbot-mac-discovery", targets: ["ClawdbotDiscoveryCLI"]), + .executable(name: "clawdbot-mac-wizard", targets: ["ClawdbotWizardCLI"]), ], dependencies: [ .package(url: "https://github.com/orchetect/MenuBarExtraAccess", exact: "1.2.2"), @@ -82,6 +83,15 @@ let package = Package( swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ]), + .executableTarget( + name: "ClawdbotWizardCLI", + dependencies: [ + "ClawdbotProtocol", + ], + path: "Sources/ClawdbotWizardCLI", + swiftSettings: [ + .enableUpcomingFeature("StrictConcurrency"), + ]), .testTarget( name: "ClawdbotIPCTests", dependencies: [ diff --git a/apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift b/apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift new file mode 100644 index 000000000..d410914bf --- /dev/null +++ b/apps/macos/Sources/ClawdbotProtocol/WizardHelpers.swift @@ -0,0 +1,106 @@ +import Foundation + +public struct WizardOption: Sendable { + public let value: AnyCodable? + public let label: String + public let hint: String? + + public init(value: AnyCodable?, label: String, hint: String?) { + self.value = value + self.label = label + self.hint = hint + } +} + +public func decodeWizardStep(_ raw: [String: AnyCodable]?) -> WizardStep? { + guard let raw else { return nil } + do { + let data = try JSONEncoder().encode(raw) + return try JSONDecoder().decode(WizardStep.self, from: data) + } catch { + return nil + } +} + +public func parseWizardOptions(_ raw: [[String: AnyCodable]]?) -> [WizardOption] { + guard let raw else { return [] } + return raw.map { entry in + let value = entry["value"] + let label = (entry["label"]?.value as? String) ?? "" + let hint = entry["hint"]?.value as? String + return WizardOption(value: value, label: label, hint: hint) + } +} + +public func wizardStatusString(_ value: AnyCodable?) -> String? { + (value?.value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() +} + +public func wizardStepType(_ step: WizardStep) -> String { + (step.type.value as? String) ?? "" +} + +public func anyCodableString(_ value: AnyCodable?) -> String { + switch value?.value { + case let string as String: + string + case let int as Int: + String(int) + case let double as Double: + String(double) + case let bool as Bool: + bool ? "true" : "false" + default: + "" + } +} + +public func anyCodableBool(_ value: AnyCodable?) -> Bool { + switch value?.value { + case let bool as Bool: + return bool + case let int as Int: + return int != 0 + case let double as Double: + return double != 0 + case let string as String: + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimmed == "true" || trimmed == "1" || trimmed == "yes" + default: + return false + } +} + +public func anyCodableArray(_ value: AnyCodable?) -> [AnyCodable] { + switch value?.value { + case let arr as [AnyCodable]: + return arr + case let arr as [Any]: + return arr.map { AnyCodable($0) } + default: + return [] + } +} + +public func anyCodableEqual(_ lhs: AnyCodable?, _ rhs: AnyCodable?) -> Bool { + switch (lhs?.value, rhs?.value) { + case let (l as String, r as String): + l == r + case let (l as Int, r as Int): + l == r + case let (l as Double, r as Double): + l == r + case let (l as Bool, r as Bool): + l == r + case let (l as String, r as Int): + l == String(r) + case let (l as Int, r as String): + String(l) == r + case let (l as String, r as Double): + l == String(r) + case let (l as Double, r as String): + String(l) == r + default: + false + } +} diff --git a/apps/macos/Sources/ClawdbotWizardCLI/main.swift b/apps/macos/Sources/ClawdbotWizardCLI/main.swift new file mode 100644 index 000000000..58a83681b --- /dev/null +++ b/apps/macos/Sources/ClawdbotWizardCLI/main.swift @@ -0,0 +1,543 @@ +import ClawdbotProtocol +import Darwin +import Foundation + +struct WizardCliOptions { + var url: String? + var token: String? + var password: String? + var mode: String = "local" + var workspace: String? + var json: Bool = false + var help: Bool = false + + static func parse(_ args: [String]) -> WizardCliOptions { + var opts = WizardCliOptions() + 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 "--url": + opts.url = nextValue(args, index: &i) + case "--token": + opts.token = nextValue(args, index: &i) + case "--password": + opts.password = nextValue(args, index: &i) + case "--mode": + if let value = nextValue(args, index: &i) { + opts.mode = value + } + case "--workspace": + opts.workspace = nextValue(args, index: &i) + 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 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 + case gatewayError(String) + case decodeError(String) + case cancelled + + var description: String { + switch self { + case let .invalidUrl(raw): "Invalid URL: \(raw)" + case .missingRemoteUrl: "gateway.remote.url is missing" + case let .gatewayError(msg): msg + case let .decodeError(msg): msg + case .cancelled: "Wizard cancelled" + } + } +} + +@main +struct ClawdbotWizardCLI { + static func main() async { + let opts = WizardCliOptions.parse(Array(CommandLine.arguments.dropFirst())) + if opts.help { + printUsage() + return + } + + 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) + } + } +} + +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 { + 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)) + } + + let mode = (config.mode ?? "local").lowercased() + if mode == "remote" { + guard let raw = config.remoteUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { + throw WizardCliError.missingRemoteUrl + } + 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)) + } + + let port = config.port ?? 18789 + let host = "127.0.0.1" + guard let url = URL(string: "ws://\(host):\(port)") else { + throw WizardCliError.invalidUrl("ws://\(host):\(port)") + } + return GatewayEndpoint( + url: url, + token: resolvedToken(opts: opts, config: config), + password: resolvedPassword(opts: opts, config: config)) +} + +private func resolvedToken(opts: WizardCliOptions, 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 (config.mode ?? "local").lowercased() == "remote" { + return config.remoteToken + } + return config.token +} + +private func resolvedPassword(opts: WizardCliOptions, 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 (config.mode ?? "local").lowercased() == "remote" { + return config.remotePassword + } + return config.password +} + +private func loadGatewayConfig() -> GatewayConfig { + let url = FileManager.default.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 let url: URL + private let token: String? + private let password: String? + private let json: Bool + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private let session = URLSession(configuration: .default) + private var task: URLSessionWebSocketTask? + + init(url: URL, token: String?, password: String?, json: Bool) { + self.url = url + self.token = token + self.password = password + self.json = json + } + + func connect() async throws { + let socket = self.session.webSocketTask(with: self.url) + socket.maximumMessageSize = 16 * 1024 * 1024 + socket.resume() + self.task = socket + try await self.sendConnect() + } + + func close() { + self.task?.cancel(with: .goingAway, reason: nil) + self.task = nil + } + + func request(method: String, params: [String: AnyCodable]?) async throws -> ResponseFrame { + guard let task = self.task else { + throw WizardCliError.gatewayError("gateway not connected") + } + let id = UUID().uuidString + let frame = RequestFrame( + type: "req", + id: id, + method: method, + params: params.map { AnyCodable($0) }) + let data = try self.encoder.encode(frame) + try await task.send(.data(data)) + + while true { + let message = try await task.receive() + let frame = try decodeFrame(message) + if case let .res(res) = frame, res.id == id { + if res.ok == false { + let msg = (res.error?["message"]?.value as? String) ?? "gateway error" + throw WizardCliError.gatewayError(msg) + } + return res + } + } + } + + func decodePayload(_ response: ResponseFrame, as _: T.Type) throws -> T { + guard let payload = response.payload else { + throw WizardCliError.decodeError("missing payload") + } + let data = try self.encoder.encode(payload) + return try self.decoder.decode(T.self, from: data) + } + + private func decodeFrame(_ message: URLSessionWebSocketTask.Message) throws -> GatewayFrame { + let data: Data? = switch message { + case let .data(data): data + case let .string(text): text.data(using: .utf8) + @unknown default: nil + } + guard let data else { + throw WizardCliError.decodeError("empty gateway response") + } + return try self.decoder.decode(GatewayFrame.self, from: data) + } + + private func sendConnect() async throws { + guard let task = self.task else { + throw WizardCliError.gatewayError("gateway not connected") + } + let osVersion = ProcessInfo.processInfo.operatingSystemVersion + let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" + let client: [String: AnyCodable] = [ + "id": AnyCodable("clawdbot-macos"), + "displayName": AnyCodable(Host.current().localizedName ?? "Clawdbot macOS Wizard CLI"), + "version": AnyCodable("dev"), + "platform": AnyCodable(platform), + "deviceFamily": AnyCodable("Mac"), + "mode": AnyCodable("ui"), + "instanceId": AnyCodable(UUID().uuidString), + ] + + var params: [String: AnyCodable] = [ + "minProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION), + "maxProtocol": AnyCodable(GATEWAY_PROTOCOL_VERSION), + "client": AnyCodable(client), + "caps": AnyCodable([String]()), + "locale": AnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier), + "userAgent": AnyCodable(ProcessInfo.processInfo.operatingSystemVersionString), + ] + if let token = self.token { + params["auth"] = AnyCodable(["token": AnyCodable(token)]) + } else if let password = self.password { + params["auth"] = AnyCodable(["password": AnyCodable(password)]) + } + + let reqId = UUID().uuidString + let frame = RequestFrame( + type: "req", + id: reqId, + method: "connect", + params: AnyCodable(params)) + let data = try self.encoder.encode(frame) + try await task.send(.data(data)) + + let message = try await task.receive() + let frameResponse = try decodeFrame(message) + guard case let .res(res) = frameResponse, res.id == reqId else { + throw WizardCliError.gatewayError("connect failed (unexpected response)") + } + if res.ok == false { + let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed" + throw WizardCliError.gatewayError(msg) + } + _ = try self.decodePayload(res, as: HelloOk.self) + } +} + +private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws { + var params: [String: AnyCodable] = [:] + let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if mode == "local" || mode == "remote" { + params["mode"] = AnyCodable(mode) + } + if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty { + params["workspace"] = AnyCodable(workspace) + } + + let startResponse = try await client.request(method: "wizard.start", params: params) + let startResult = try await client.decodePayload(startResponse, as: WizardStartResult.self) + if opts.json { + dumpResult(startResponse) + } + + let sessionId = startResult.sessionid + var nextResult = WizardNextResult( + done: startResult.done, + step: startResult.step, + status: startResult.status, + error: startResult.error) + + do { + while true { + let status = wizardStatusString(nextResult.status) ?? (nextResult.done ? "done" : "running") + if status == "done" || nextResult.done { + print("Wizard complete.") + return + } + if status == "cancelled" { + print("Wizard cancelled.") + return + } + if status == "error" { + throw WizardCliError.gatewayError(nextResult.error ?? "wizard error") + } + + if let step = decodeWizardStep(nextResult.step) { + let answer = try promptAnswer(for: step) + var answerPayload: [String: AnyCodable] = [ + "stepId": AnyCodable(step.id), + ] + if !(answer is NSNull) { + answerPayload["value"] = AnyCodable(answer) + } + let response = try await client.request( + method: "wizard.next", + params: [ + "sessionId": AnyCodable(sessionId), + "answer": AnyCodable(answerPayload), + ]) + nextResult = try await client.decodePayload(response, as: WizardNextResult.self) + if opts.json { + dumpResult(response) + } + } else { + let response = try await client.request( + method: "wizard.next", + params: ["sessionId": AnyCodable(sessionId)]) + nextResult = try await client.decodePayload(response, as: WizardNextResult.self) + if opts.json { + dumpResult(response) + } + } + } + } catch WizardCliError.cancelled { + _ = try? await client.request( + method: "wizard.cancel", + params: ["sessionId": AnyCodable(sessionId)]) + throw WizardCliError.cancelled + } +} + +private func dumpResult(_ response: ResponseFrame) { + guard let payload = response.payload else { + print("{\"error\":\"missing payload\"}") + return + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + if let data = try? encoder.encode(payload), let text = String(data: data, encoding: .utf8) { + print(text) + } +} + +private func promptAnswer(for step: WizardStep) throws -> Any { + let type = wizardStepType(step) + if let title = step.title, !title.isEmpty { + print("\n\(title)") + } + if let message = step.message, !message.isEmpty { + print(message) + } + + switch type { + case "note": + _ = try readLineWithPrompt("Continue? (enter)") + return NSNull() + case "progress": + _ = try readLineWithPrompt("Continue? (enter)") + return NSNull() + case "action": + _ = try readLineWithPrompt("Run? (enter)") + return true + case "text": + let initial = anyCodableString(step.initialvalue) + let prompt = step.placeholder ?? "Value" + let value = try readLineWithPrompt("\(prompt)\(initial.isEmpty ? "" : " [\(initial)]")") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? initial : trimmed + case "confirm": + let initial = anyCodableBool(step.initialvalue) + let value = try readLineWithPrompt("Confirm? (y/n) [\(initial ? "y" : "n")]") + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if trimmed.isEmpty { return initial } + return trimmed == "y" || trimmed == "yes" || trimmed == "true" + case "select": + return try promptSelect(step) + case "multiselect": + return try promptMultiSelect(step) + default: + _ = try readLineWithPrompt("Continue? (enter)") + return NSNull() + } +} + +private func promptSelect(_ step: WizardStep) throws -> Any { + let options = parseWizardOptions(step.options) + guard !options.isEmpty else { return NSNull() } + for (idx, option) in options.enumerated() { + let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : "" + print(" [\(idx + 1)] \(option.label)\(hint)") + } + let initialIndex = options.firstIndex(where: { anyCodableEqual($0.value, step.initialvalue) }) + let defaultLabel = initialIndex.map { " [\($0 + 1)]" } ?? "" + while true { + let input = try readLineWithPrompt("Select one\(defaultLabel)") + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty, let initialIndex { + return options[initialIndex].value?.value ?? options[initialIndex].label + } + if trimmed.lowercased() == "q" { throw WizardCliError.cancelled } + if let number = Int(trimmed), (1...options.count).contains(number) { + let option = options[number - 1] + return option.value?.value ?? option.label + } + print("Invalid selection.") + } +} + +private func promptMultiSelect(_ step: WizardStep) throws -> [Any] { + let options = parseWizardOptions(step.options) + guard !options.isEmpty else { return [] } + for (idx, option) in options.enumerated() { + let hint = option.hint?.isEmpty == false ? " — \(option.hint!)" : "" + print(" [\(idx + 1)] \(option.label)\(hint)") + } + let initialValues = anyCodableArray(step.initialvalue) + let initialIndices = options.enumerated().compactMap { index, option in + initialValues.contains { anyCodableEqual($0, option.value) } ? index + 1 : nil + } + let defaultLabel = initialIndices.isEmpty ? "" : " [\(initialIndices.map(String.init).joined(separator: ","))]" + while true { + let input = try readLineWithPrompt("Select (comma-separated)\(defaultLabel)") + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return initialIndices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label } + } + if trimmed.lowercased() == "q" { throw WizardCliError.cancelled } + let parts = trimmed.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let indices = parts.compactMap { Int($0) }.filter { (1...options.count).contains($0) } + if indices.isEmpty { + print("Invalid selection.") + continue + } + return indices.map { options[$0 - 1].value?.value ?? options[$0 - 1].label } + } +} + +private func readLineWithPrompt(_ prompt: String) throws -> String { + print("\(prompt): ", terminator: "") + guard let line = readLine() else { + throw WizardCliError.cancelled + } + return line +}