Files
clawdbot/apps/macos/Sources/ClawdbotMacCLI/WizardCommand.swift
2026-01-27 12:21:02 +00:00

554 lines
21 KiB
Swift

import MoltbotKit
import MoltbotProtocol
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 = 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 = nextValue(args, index: &i) {
opts.mode = value
}
case "--workspace":
opts.workspace = self.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)
}
}
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"
}
}
}
func runWizardCommand(_ args: [String]) async {
let opts = WizardCliOptions.parse(args)
if opts.help {
print("""
moltbot-mac wizard
Usage:
moltbot-mac wizard [--url <ws://host:port>] [--token <token>] [--password <password>]
[--mode <local|remote>] [--workspace <path>] [--json]
Options:
--url <url> Gateway WebSocket URL (overrides config)
--token <token> Gateway token (if required)
--password <pw> Gateway password (if required)
--mode <mode> Wizard mode (local|remote). Default: local
--workspace <path> 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 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),
mode: (config.mode ?? "local").lowercased())
}
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),
mode: mode)
}
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),
mode: mode)
}
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
}
actor GatewayWizardClient {
private enum ConnectChallengeError: Error {
case timeout
}
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 let connectChallengeTimeoutSeconds: Double = 0.75
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: ProtoAnyCodable]?) 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 { ProtoAnyCodable($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<T: Decodable>(_ 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 clientId = "moltbot-macos"
let clientMode = "ui"
let role = "operator"
let scopes: [String] = []
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "Moltbot macOS Wizard CLI"),
"version": ProtoAnyCodable("dev"),
"platform": ProtoAnyCodable(platform),
"deviceFamily": ProtoAnyCodable("Mac"),
"mode": ProtoAnyCodable(clientMode),
"instanceId": ProtoAnyCodable(UUID().uuidString),
]
var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([String]()),
"locale": ProtoAnyCodable(Locale.preferredLanguages.first ?? Locale.current.identifier),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable(role),
"scopes": ProtoAnyCodable(scopes),
]
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password {
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
}
let connectNonce = try await self.waitForConnectChallenge()
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopesValue = scopes.joined(separator: ",")
var payloadParts = [
connectNonce == nil ? "v1" : "v2",
identity.deviceId,
clientId,
clientMode,
role,
scopesValue,
String(signedAtMs),
self.token ?? "",
]
if let connectNonce {
payloadParts.append(connectNonce)
}
let payload = payloadParts.joined(separator: "|")
if let signature = DeviceIdentityStore.signPayload(payload, identity: identity),
let publicKey = DeviceIdentityStore.publicKeyBase64Url(identity)
{
var device: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(identity.deviceId),
"publicKey": ProtoAnyCodable(publicKey),
"signature": ProtoAnyCodable(signature),
"signedAt": ProtoAnyCodable(signedAtMs),
]
if let connectNonce {
device["nonce"] = ProtoAnyCodable(connectNonce)
}
params["device"] = ProtoAnyCodable(device)
}
let reqId = UUID().uuidString
let frame = RequestFrame(
type: "req",
id: reqId,
method: "connect",
params: ProtoAnyCodable(params))
let data = try self.encoder.encode(frame)
try await task.send(.data(data))
while true {
let message = try await task.receive()
let frameResponse = try decodeFrame(message)
if case let .res(res) = frameResponse, res.id == reqId {
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)
return
}
}
}
private func waitForConnectChallenge() async throws -> String? {
guard let task = self.task else { return nil }
do {
return try await AsyncTimeout.withTimeout(
seconds: self.connectChallengeTimeoutSeconds,
onTimeout: { ConnectChallengeError.timeout },
operation: {
while true {
let message = try await task.receive()
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
{
return nonce
}
}
}
})
} catch {
if error is ConnectChallengeError { return nil }
throw error
}
}
}
private func runWizard(client: GatewayWizardClient, opts: WizardCliOptions) async throws {
var params: [String: ProtoAnyCodable] = [:]
let mode = opts.mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if mode == "local" || mode == "remote" {
params["mode"] = ProtoAnyCodable(mode)
}
if let workspace = opts.workspace?.trimmingCharacters(in: .whitespacesAndNewlines), !workspace.isEmpty {
params["workspace"] = ProtoAnyCodable(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 == "cancelled" {
print("Wizard cancelled.")
return
}
if status == "error" || (nextResult.done && nextResult.error != nil) {
throw WizardCliError.gatewayError(nextResult.error ?? "wizard error")
}
if status == "done" || nextResult.done {
print("Wizard complete.")
return
}
if let step = decodeWizardStep(nextResult.step) {
let answer = try promptAnswer(for: step)
var answerPayload: [String: ProtoAnyCodable] = [
"stepId": ProtoAnyCodable(step.id),
]
if !(answer is NSNull) {
answerPayload["value"] = ProtoAnyCodable(answer)
}
let response = try await client.request(
method: "wizard.next",
params: [
"sessionId": ProtoAnyCodable(sessionId),
"answer": ProtoAnyCodable(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": ProtoAnyCodable(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": ProtoAnyCodable(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
}