diff --git a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift index 6a8e78b5d..ab2c62813 100644 --- a/apps/macos/Sources/Clawdis/ControlRequestHandler.swift +++ b/apps/macos/Sources/Clawdis/ControlRequestHandler.swift @@ -14,7 +14,9 @@ enum ControlRequestHandler { var modelIdentifier: String? var remoteAddress: String? var connected: Bool + var paired: Bool var capabilities: [String]? + var commands: [String]? } struct NodeListResult: Codable { @@ -34,7 +36,9 @@ enum ControlRequestHandler { var modelIdentifier: String? var remoteIp: String? var connected: Bool? + var paired: Bool? var caps: [String]? + var commands: [String]? } var ts: Int? @@ -109,6 +113,9 @@ enum ControlRequestHandler { case .nodeList: return await self.handleNodeList() + case let .nodeDescribe(nodeId): + return await self.handleNodeDescribe(nodeId: nodeId) + case let .nodeInvoke(nodeId, command, paramsJSON): return await self.handleNodeInvoke( nodeId: nodeId, @@ -448,6 +455,18 @@ enum ControlRequestHandler { } } + private static func handleNodeDescribe(nodeId: String) async -> Response { + do { + let data = try await GatewayConnection.shared.request( + method: "node.describe", + params: ["nodeId": AnyCodable(nodeId)], + timeoutMs: 10_000) + return Response(ok: true, payload: data) + } catch { + return Response(ok: false, message: error.localizedDescription) + } + } + static func buildNodeListResult(payload: GatewayNodeListPayload) -> NodeListResult { let nodes = payload.nodes.map { n -> NodeListNode in NodeListNode( @@ -459,14 +478,16 @@ enum ControlRequestHandler { modelIdentifier: n.modelIdentifier, remoteAddress: n.remoteIp, connected: n.connected == true, - capabilities: n.caps) + paired: n.paired == true, + capabilities: n.caps, + commands: n.commands) } let sorted = nodes.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) } - let pairedNodeIds = sorted.map(\.nodeId).sorted() + let pairedNodeIds = sorted.filter(\.paired).map(\.nodeId).sorted() let connectedNodeIds = sorted.filter(\.connected).map(\.nodeId).sorted() return NodeListResult( diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index 0a0b8e5ea..20d138814 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -49,6 +49,7 @@ struct ClawdisCLI { private struct ParsedCLIRequest { var request: Request var kind: Kind + var verbose: Bool = false enum Kind { case generic @@ -215,7 +216,32 @@ struct ClawdisCLI { guard let sub = args.popFirst() else { throw CLIError.help } switch sub { case "list": - return ParsedCLIRequest(request: .nodeList, kind: .generic) + var verbose = false + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--verbose": + verbose = true + default: + break + } + } + return ParsedCLIRequest(request: .nodeList, kind: .generic, verbose: verbose) + + case "describe": + var nodeId: String? + while !args.isEmpty { + let arg = args.removeFirst() + switch arg { + case "--node": + nodeId = args.popFirst() + default: + if nodeId == nil { nodeId = arg } + } + } + guard let nodeId else { throw CLIError.help } + return ParsedCLIRequest(request: .nodeDescribe(nodeId: nodeId), kind: .generic) + case "invoke": var nodeId: String? var command: String? @@ -438,11 +464,15 @@ struct ClawdisCLI { struct Node: Decodable { var nodeId: String var displayName: String? + var platform: String? + var version: String? var deviceFamily: String? var modelIdentifier: String? var remoteAddress: String? var connected: Bool + var paired: Bool? var capabilities: [String]? + var commands: [String]? } var pairedNodeIds: [String]? @@ -474,9 +504,74 @@ struct ClawdisCLI { if let ip { parts.append(ip) } if let family { parts.append("device: \(family)") } if let model { parts.append("hw: \(model)") } + let paired = n.paired ?? true + parts.append(paired ? "paired" : "unpaired") parts.append(n.connected ? "connected" : "disconnected") parts.append("caps: \(capsText)") print(parts.joined(separator: " · ")) + + if parsed.verbose { + let platform = (n.platform ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let version = (n.version ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + if !platform.isEmpty || !version.isEmpty { + let pv = [platform.isEmpty ? nil : platform, version.isEmpty ? nil : version] + .compactMap { $0 } + .joined(separator: " ") + if !pv.isEmpty { print(" platform: \(pv)") } + } + + let commands = n.commands?.sorted() ?? [] + if !commands.isEmpty { + print(" commands: \(commands.joined(separator: ", "))") + } + } + } + return + } + } + + if case .nodeDescribe = parsed.request, let payload = response.payload { + struct NodeDescribeResult: Decodable { + var nodeId: String + var displayName: String? + var platform: String? + var version: String? + var deviceFamily: String? + var modelIdentifier: String? + var remoteIp: String? + var caps: [String]? + var commands: [String]? + var paired: Bool? + var connected: Bool? + } + + if let decoded = try? JSONDecoder().decode(NodeDescribeResult.self, from: payload) { + let nameTrimmed = decoded.displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let name = nameTrimmed.isEmpty ? decoded.nodeId : nameTrimmed + + let ipTrimmed = decoded.remoteIp?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let ip = ipTrimmed.isEmpty ? nil : ipTrimmed + + let familyTrimmed = decoded.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let family = familyTrimmed.isEmpty ? nil : familyTrimmed + let modelTrimmed = decoded.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let model = modelTrimmed.isEmpty ? nil : modelTrimmed + + let caps = decoded.caps?.sorted().joined(separator: ",") + let capsText = caps.map { "[\($0)]" } ?? "?" + let commands = decoded.commands?.sorted() ?? [] + + var parts: [String] = ["Node:", name, decoded.nodeId] + if let ip { parts.append(ip) } + if let family { parts.append("device: \(family)") } + if let model { parts.append("hw: \(model)") } + if let paired = decoded.paired { parts.append(paired ? "paired" : "unpaired") } + if let connected = decoded.connected { parts.append(connected ? "connected" : "disconnected") } + parts.append("caps: \(capsText)") + print(parts.joined(separator: " · ")) + if !commands.isEmpty { + print("Commands:") + for c in commands { print("- \(c)") } } return } @@ -558,7 +653,8 @@ struct ClawdisCLI { [--session ] [--deliver] [--to ] Nodes: - clawdis-mac node list # paired + connected nodes (+ capabilities when available) + clawdis-mac node list [--verbose] # paired + connected nodes (+ capabilities when available) + clawdis-mac node describe --node clawdis-mac node invoke --node --command [--params-json ] Canvas: diff --git a/apps/macos/Sources/ClawdisIPC/IPC.swift b/apps/macos/Sources/ClawdisIPC/IPC.swift index 2d592b217..364010326 100644 --- a/apps/macos/Sources/ClawdisIPC/IPC.swift +++ b/apps/macos/Sources/ClawdisIPC/IPC.swift @@ -128,6 +128,7 @@ public enum Request: Sendable { case canvasSnapshot(session: String, outPath: String?) case canvasA2UI(session: String, command: CanvasA2UICommand, jsonl: String?) case nodeList + case nodeDescribe(nodeId: String) case nodeInvoke(nodeId: String, command: String, paramsJSON: String?) case cameraSnap(facing: CameraFacing?, maxWidth: Int?, quality: Double?, outPath: String?) case cameraClip(facing: CameraFacing?, durationMs: Int?, includeAudio: Bool, outPath: String?) @@ -187,6 +188,7 @@ extension Request: Codable { case canvasSnapshot case canvasA2UI case nodeList + case nodeDescribe case nodeInvoke case cameraSnap case cameraClip @@ -259,6 +261,10 @@ extension Request: Codable { case .nodeList: try container.encode(Kind.nodeList, forKey: .type) + case let .nodeDescribe(nodeId): + try container.encode(Kind.nodeDescribe, forKey: .type) + try container.encode(nodeId, forKey: .nodeId) + case let .nodeInvoke(nodeId, command, paramsJSON): try container.encode(Kind.nodeInvoke, forKey: .type) try container.encode(nodeId, forKey: .nodeId) @@ -349,6 +355,10 @@ extension Request: Codable { case .nodeList: self = .nodeList + case .nodeDescribe: + let nodeId = try container.decode(String.self, forKey: .nodeId) + self = .nodeDescribe(nodeId: nodeId) + case .nodeInvoke: let nodeId = try container.decode(String.self, forKey: .nodeId) let command = try container.decode(String.self, forKey: .nodeCommand) diff --git a/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift b/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift index 0795ce57d..83493160b 100644 --- a/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift +++ b/apps/macos/Tests/ClawdisIPCTests/NodeListTests.swift @@ -15,6 +15,7 @@ import Testing modelIdentifier: "iPad14,5", remoteIp: "192.168.0.88", connected: true, + paired: true, caps: ["canvas", "camera"]), ControlRequestHandler.GatewayNodeListPayload.Node( nodeId: "n2", @@ -25,6 +26,7 @@ import Testing modelIdentifier: "iPhone14,2", remoteIp: nil, connected: false, + paired: true, caps: nil), ])