From 80a87e5f9e0174ebc14fe3cfb782eb057fd29b30 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 01:06:27 +0000 Subject: [PATCH] refactor(mac): remove clawdis-mac browser cli --- .../macos/Sources/ClawdisCLI/BrowserCLI.swift | 619 ------------------ .../macos/Sources/ClawdisCLI/ClawdisCLI.swift | 22 - .../ClawdisCLITests/BrowserCLITests.swift | 20 - 3 files changed, 661 deletions(-) delete mode 100644 apps/macos/Sources/ClawdisCLI/BrowserCLI.swift delete mode 100644 apps/macos/Tests/ClawdisCLITests/BrowserCLITests.swift diff --git a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift b/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift deleted file mode 100644 index f242ab2e9..000000000 --- a/apps/macos/Sources/ClawdisCLI/BrowserCLI.swift +++ /dev/null @@ -1,619 +0,0 @@ -import Darwin -import Foundation - -enum BrowserCLI { - private static let defaultControlURL = "http://127.0.0.1:18791" - - static func run(args: [String], jsonOutput: Bool) async throws -> Int32 { - var args = args - guard let sub = args.popFirst() else { - self.printHelp() - return 0 - } - - if sub == "--help" || sub == "-h" || sub == "help" { - self.printHelp() - return 0 - } - - let options = self.parseOptions(args: args) - - let cfg = self.loadBrowserConfig() - guard cfg.enabled else { - if jsonOutput { - self.printJSON(ok: false, result: ["error": "browser control disabled"]) - } else { - print("Browser control is disabled in ~/.clawdis/clawdis.json (browser.enabled=false).") - } - return 1 - } - - let base = (options.overrideURL ?? cfg.controlUrl).trimmingCharacters(in: .whitespacesAndNewlines) - guard let baseURL = URL(string: base) else { - throw NSError(domain: "BrowserCLI", code: 1, userInfo: [ - NSLocalizedDescriptionKey: "Invalid browser control URL: \(base)", - ]) - } - - do { - return try await self.runCommand(sub: sub, options: options, baseURL: baseURL, jsonOutput: jsonOutput) - } catch { - let msg = self.describeError(error, baseURL: baseURL) - if jsonOutput { - self.printJSON(ok: false, result: ["error": msg]) - } else { - fputs("\(msg)\n", stderr) - } - return 1 - } - } - - private struct RunOptions { - var overrideURL: String? - var fullPage: Bool = false - var targetId: String? - var awaitPromise: Bool = false - var js: String? - var jsFile: String? - var jsStdin: Bool = false - var selector: String? - var format: String? - var limit: Int? - var maxChars: Int? - var outPath: String? - var rest: [String] = [] - } - - private static func parseOptions(args: [String]) -> RunOptions { - var args = args - var options = RunOptions() - while !args.isEmpty { - let arg = args.removeFirst() - switch arg { - case "--url": - options.overrideURL = args.popFirst() - case "--full-page": - options.fullPage = true - case "--target-id": - options.targetId = args.popFirst() - case "--await": - options.awaitPromise = true - case "--js": - options.js = args.popFirst() - case "--js-file": - options.jsFile = args.popFirst() - case "--js-stdin": - options.jsStdin = true - case "--selector": - options.selector = args.popFirst() - case "--format": - options.format = args.popFirst() - case "--limit": - options.limit = args.popFirst().flatMap(Int.init) - case "--max-chars": - options.maxChars = args.popFirst().flatMap(Int.init) - case "--out": - options.outPath = args.popFirst() - default: - options.rest.append(arg) - } - } - return options - } - - private static func runCommand( - sub: String, - options: RunOptions, - baseURL: URL, - jsonOutput: Bool) async throws -> Int32 - { - switch sub { - case "status": - return try await self.handleStatus(baseURL: baseURL, jsonOutput: jsonOutput) - case "start": - return try await self.handleStartStop(action: "start", baseURL: baseURL, jsonOutput: jsonOutput) - case "stop": - return try await self.handleStartStop(action: "stop", baseURL: baseURL, jsonOutput: jsonOutput) - case "tabs": - return try await self.handleTabs(baseURL: baseURL, jsonOutput: jsonOutput) - case "open": - return try await self.handleOpen(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "focus": - return try await self.handleFocus(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "close": - return try await self.handleClose(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "screenshot": - return try await self.handleScreenshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "eval": - return try await self.handleEval(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "query": - return try await self.handleQuery(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "dom": - return try await self.handleDOM(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - case "snapshot": - return try await self.handleSnapshot(baseURL: baseURL, jsonOutput: jsonOutput, options: options) - default: - self.printHelp() - return 2 - } - } - - private static func handleStatus(baseURL: URL, jsonOutput: Bool) async throws -> Int32 { - let res = try await self.httpJSON(method: "GET", url: baseURL.appendingPathComponent("/")) - self.printResult(jsonOutput: jsonOutput, res: res) - return 0 - } - - private static func handleStartStop(action: String, baseURL: URL, jsonOutput: Bool) async throws -> Int32 { - let url = baseURL.appendingPathComponent("/\(action)") - let res = try await self.httpJSON(method: "POST", url: url, timeoutInterval: 15.0) - self.printResult(jsonOutput: jsonOutput, res: res) - return 0 - } - - private static func handleTabs(baseURL: URL, jsonOutput: Bool) async throws -> Int32 { - let url = baseURL.appendingPathComponent("/tabs") - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 3.0) - if jsonOutput { - self.printJSON(ok: true, result: res) - } else { - self.printTabs(res: res) - } - return 0 - } - - private static func handleOpen(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - guard let urlString = options.rest.first, !urlString.isEmpty else { - self.printHelp() - return 2 - } - let url = baseURL.appendingPathComponent("/tabs/open") - let res = try await self.httpJSON( - method: "POST", - url: url, - body: ["url": urlString], - timeoutInterval: 15.0) - self.printResult(jsonOutput: jsonOutput, res: res) - return 0 - } - - private static func handleFocus(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - guard let id = options.rest.first, !id.isEmpty else { - self.printHelp() - return 2 - } - let url = baseURL.appendingPathComponent("/tabs/focus") - let res = try await self.httpJSON( - method: "POST", - url: url, - body: ["targetId": id], - timeoutInterval: 5.0) - self.printResult(jsonOutput: jsonOutput, res: res) - return 0 - } - - private static func handleClose(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - guard let id = options.rest.first, !id.isEmpty else { - self.printHelp() - return 2 - } - let url = baseURL.appendingPathComponent("/tabs/\(id)") - let res = try await self.httpJSON(method: "DELETE", url: url, timeoutInterval: 5.0) - self.printResult(jsonOutput: jsonOutput, res: res) - return 0 - } - - private static func handleScreenshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - var url = baseURL.appendingPathComponent("/screenshot") - var items: [URLQueryItem] = [] - if let targetId = options.targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if options.fullPage { - items.append(URLQueryItem(name: "fullPage", value: "1")) - } - if !items.isEmpty { - url = self.withQuery(url, items: items) - } - - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - if jsonOutput { - self.printJSON(ok: true, result: res) - } else if let path = res["path"] as? String, !path.isEmpty { - print("MEDIA:\(path)") - } else { - self.printResult(jsonOutput: false, res: res) - } - return 0 - } - - private static func handleEval(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - if options.jsStdin, options.jsFile != nil { - self.printHelp() - return 2 - } - - let code = try self.resolveEvalCode(options: options) - if code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - self.printHelp() - return 2 - } - - let url = baseURL.appendingPathComponent("/eval") - let res = try await self.httpJSON( - method: "POST", - url: url, - body: [ - "js": code, - "targetId": options.targetId ?? "", - "await": options.awaitPromise, - ], - timeoutInterval: 15.0) - - if jsonOutput { - self.printJSON(ok: true, result: res) - } else { - self.printEval(res: res) - } - return 0 - } - - private static func resolveEvalCode(options: RunOptions) throws -> String { - if let jsFile = options.jsFile, !jsFile.isEmpty { - return try String(contentsOfFile: jsFile, encoding: .utf8) - } - if options.jsStdin { - let data = FileHandle.standardInput.readToEndSafely() - return String(data: data, encoding: .utf8) ?? "" - } - if let js = options.js, !js.isEmpty { - return js - } - if !options.rest.isEmpty { - return options.rest.joined(separator: " ") - } - return "" - } - - private static func handleQuery(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - let sel = (options.selector ?? options.rest.first ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if sel.isEmpty { - self.printHelp() - return 2 - } - - var url = baseURL.appendingPathComponent("/query") - var items: [URLQueryItem] = [URLQueryItem(name: "selector", value: sel)] - if let targetId = options.targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if let limit = options.limit, limit > 0 { - items.append(URLQueryItem(name: "limit", value: String(limit))) - } - url = self.withQuery(url, items: items) - - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 15.0) - if jsonOutput || options.format == "json" { - self.printJSON(ok: true, result: res) - } else { - self.printQuery(res: res) - } - return 0 - } - - private static func handleDOM(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - let fmt = (options.format == "text") ? "text" : "html" - var url = baseURL.appendingPathComponent("/dom") - var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)] - if let targetId = options.targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if let selector = options.selector?.trimmingCharacters(in: .whitespacesAndNewlines), !selector.isEmpty { - items.append(URLQueryItem(name: "selector", value: selector)) - } - if let maxChars = options.maxChars, maxChars > 0 { - items.append(URLQueryItem(name: "maxChars", value: String(maxChars))) - } - url = self.withQuery(url, items: items) - - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - let text = (res["text"] as? String) ?? "" - if let out = options.outPath, !out.isEmpty { - try Data(text.utf8).write(to: URL(fileURLWithPath: out)) - if jsonOutput { - self.printJSON(ok: true, result: ["ok": true, "out": out]) - } else { - print(out) - } - return 0 - } - - if jsonOutput { - self.printJSON(ok: true, result: res) - } else { - print(text) - } - return 0 - } - - private static func handleSnapshot(baseURL: URL, jsonOutput: Bool, options: RunOptions) async throws -> Int32 { - let fmt = (options.format == "domSnapshot") ? "domSnapshot" : "aria" - var url = baseURL.appendingPathComponent("/snapshot") - var items: [URLQueryItem] = [URLQueryItem(name: "format", value: fmt)] - if let targetId = options.targetId, !targetId.isEmpty { - items.append(URLQueryItem(name: "targetId", value: targetId)) - } - if let limit = options.limit, limit > 0 { - items.append(URLQueryItem(name: "limit", value: String(limit))) - } - url = self.withQuery(url, items: items) - - let res = try await self.httpJSON(method: "GET", url: url, timeoutInterval: 20.0) - if let out = options.outPath, !out.isEmpty { - let data = try JSONSerialization.data(withJSONObject: res, options: [.prettyPrinted]) - try data.write(to: URL(fileURLWithPath: out)) - if jsonOutput { - self.printJSON(ok: true, result: ["ok": true, "out": out]) - } else { - print(out) - } - return 0 - } - - if jsonOutput || fmt == "domSnapshot" { - self.printJSON(ok: true, result: res) - } else { - self.printSnapshotAria(res: res) - } - return 0 - } - - private struct BrowserConfig { - let enabled: Bool - let controlUrl: String - } - - private static func loadBrowserConfig() -> BrowserConfig { - let root = self.loadConfigDict() - let browser = root["browser"] as? [String: Any] - let enabled = browser?["enabled"] as? Bool ?? true - let url = (browser?["controlUrl"] as? String) ?? self.defaultControlURL - return BrowserConfig(enabled: enabled, controlUrl: url) - } - - private static func loadConfigDict() -> [String: Any] { - let url = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".clawdis") - .appendingPathComponent("clawdis.json") - guard let data = try? Data(contentsOf: url) else { return [:] } - return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:] - } - - private static func withQuery(_ url: URL, items: [URLQueryItem]) -> URL { - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents() - components.queryItems = items - return components.url ?? url - } - - private static func httpJSON( - method: String, - url: URL, - body: [String: Any]? = nil, - timeoutInterval: TimeInterval = 2.0) async throws -> [String: Any] - { - var req = URLRequest(url: url, timeoutInterval: timeoutInterval) - req.httpMethod = method - if let body { - req.setValue("application/json", forHTTPHeaderField: "Content-Type") - req.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) - } - - let (data, resp): (Data, URLResponse) - do { - (data, resp) = try await URLSession.shared.data(for: req) - } catch { - throw self.wrapNetworkError(error, url: url, timeoutInterval: timeoutInterval) - } - let status = (resp as? HTTPURLResponse)?.statusCode ?? 0 - - guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - let text = String(data: data, encoding: .utf8) ?? "" - throw NSError(domain: "BrowserCLI", code: status, userInfo: [ - NSLocalizedDescriptionKey: "HTTP \(status) \(method) \(url): \(text)", - ]) - } - - if status >= 200, status < 300 { - return obj - } - - let msg = (obj["error"] as? String) ?? "HTTP \(status)" - throw NSError(domain: "BrowserCLI", code: status, userInfo: [ - NSLocalizedDescriptionKey: msg, - ]) - } - - private static func describeError(_ error: Error, baseURL: URL) -> String { - let ns = error as NSError - let msg = ns.localizedDescription.trimmingCharacters(in: .whitespacesAndNewlines) - if !msg.isEmpty { return msg } - return "Browser request failed (\(baseURL.absoluteString))" - } - - private static func wrapNetworkError(_ error: Error, url: URL, timeoutInterval: TimeInterval) -> Error { - let ns = error as NSError - if ns.domain == NSURLErrorDomain { - // Keep this short: this often shows up inside SSH output and agent logs. - switch ns.code { - case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost, NSURLErrorTimedOut, - NSURLErrorCannotFindHost, NSURLErrorNotConnectedToInternet, NSURLErrorDNSLookupFailed: - let base = url.absoluteString - let hint = """ - Can't reach the clawd browser control server at \(base). - Start (or restart) the Clawdis gateway (Clawdis.app menubar, or `clawdis gateway`) and try again. - """ - return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [ - NSLocalizedDescriptionKey: hint, - ]) - default: - break - } - } - let base = url.absoluteString - let generic = "Failed to reach \(base) (timeout \(Int(timeoutInterval))s)." - return NSError(domain: "BrowserCLI", code: ns.code, userInfo: [ - NSLocalizedDescriptionKey: generic, - ]) - } - - private static func printResult(jsonOutput: Bool, res: [String: Any]) { - if jsonOutput { - self.printJSON(ok: true, result: res) - return - } - if let text = res["message"] as? String, !text.isEmpty { - print(text) - } else { - print(res) - } - } - - private static func formatTabs(res: [String: Any]) -> [String] { - guard let tabs = res["tabs"] as? [[String: Any]], !tabs.isEmpty else { return [] } - var lines: [String] = [] - lines.reserveCapacity(tabs.count * 2) - for tab in tabs { - let id = (tab["targetId"] as? String) ?? "" - let title = (tab["title"] as? String) ?? "" - let url = (tab["url"] as? String) ?? "" - let shortId = id.isEmpty ? "" : String(id.prefix(8)) - lines.append("- \(shortId) \(title) \(url)") - if !id.isEmpty { - lines.append(" id: \(id)") - } - } - return lines - } - - private static func printTabs(res: [String: Any]) { - let running = (res["running"] as? Bool) ?? false - print("Running: \(running)") - for line in self.formatTabs(res: res) { - print(line) - } - } - - private static func printEval(res: [String: Any]) { - guard let obj = res["result"] as? [String: Any] else { - self.printResult(jsonOutput: false, res: res) - return - } - - if let value = obj["value"] { - if JSONSerialization.isValidJSONObject(value), - let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted]), - let text = String(data: data, encoding: .utf8) - { - print(text) - } else { - print(String(describing: value)) - } - return - } - - if let desc = obj["description"] as? String, !desc.isEmpty { - print(desc) - return - } - - self.printResult(jsonOutput: false, res: obj) - } - - private static func printQuery(res: [String: Any]) { - guard let matches = res["matches"] as? [[String: Any]] else { - self.printResult(jsonOutput: false, res: res) - return - } - if matches.isEmpty { - print("No matches.") - return - } - for m in matches { - let index = (m["index"] as? Int) ?? 0 - let tag = (m["tag"] as? String) ?? "" - let id = (m["id"] as? String).map { "#\($0)" } ?? "" - let className = (m["className"] as? String) ?? "" - let classes = className.split(separator: " ").prefix(3).map(String.init) - let cls = classes.isEmpty ? "" : "." + classes.joined(separator: ".") - let head = "\(index). <\(tag)\(id)\(cls)>" - print(head) - if let text = m["text"] as? String, !text.isEmpty { - print(" \(text)") - } - } - } - - private static func printSnapshotAria(res: [String: Any]) { - guard let nodes = res["nodes"] as? [[String: Any]] else { - self.printResult(jsonOutput: false, res: res) - return - } - for n in nodes { - let depth = (n["depth"] as? Int) ?? 0 - let role = (n["role"] as? String) ?? "unknown" - let name = (n["name"] as? String) ?? "" - let value = (n["value"] as? String) ?? "" - let indent = String(repeating: " ", count: min(depth, 20)) - var line = "\(indent)- \(role)" - if !name.isEmpty { line += " \"\(name)\"" } - if !value.isEmpty { line += " = \"\(value)\"" } - print(line) - } - } - - #if SWIFT_PACKAGE - static func _testFormatTabs(res: [String: Any]) -> [String] { - self.formatTabs(res: res) - } - #endif - - private static func printJSON(ok: Bool, result: Any) { - let obj: [String: Any] = ["ok": ok, "result": result] - if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]), - let text = String(data: data, encoding: .utf8) - { - print(text) - } else { - print("{\"ok\":false,\"error\":\"failed to encode json\"}") - } - } - - private static func printHelp() { - let usage = """ - Browser (clawd) — control clawd’s dedicated Chrome/Chromium via the gateway’s loopback server. - - Usage: - clawdis-mac browser status [--url ] - clawdis-mac browser start [--url <...>] - clawdis-mac browser stop [--url <...>] - clawdis-mac browser tabs [--url <...>] - clawdis-mac browser open [--url <...>] - clawdis-mac browser focus [--url <...>] - clawdis-mac browser close [--url <...>] - clawdis-mac browser screenshot [--target-id ] [--full-page] [--url <...>] - clawdis-mac browser eval [] [--js ] [--js-file ] [--js-stdin] - [--target-id ] [--await] [--url <...>] - clawdis-mac browser query [--limit ] [--format ] - [--target-id ] [--url <...>] - clawdis-mac browser dom [--format ] [--selector ] [--max-chars ] - [--out ] [--target-id ] [--url <...>] - clawdis-mac browser snapshot [--format ] [--limit ] [--out ] - [--target-id ] [--url <...>] - - Notes: - - Config defaults come from ~/.clawdis/clawdis.json (browser.enabled, browser.controlUrl). - - `browser screenshot` prints MEDIA: in text mode. - """ - print(usage) - } -} diff --git a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift index a319491f6..5eecb0865 100644 --- a/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift +++ b/apps/macos/Sources/ClawdisCLI/ClawdisCLI.swift @@ -11,11 +11,6 @@ struct ClawdisCLI { let jsonOutput = args.contains("--json") args.removeAll(where: { $0 == "--json" }) - if args.first == "browser" { - let code = try await BrowserCLI.run(args: Array(args.dropFirst()), jsonOutput: jsonOutput) - exit(code) - } - let parsed = try parseCommandLine(args: args) let response = try await send(request: parsed.request) @@ -778,33 +773,16 @@ struct ClawdisCLI { clawdis-mac screen record [--screen ] [--duration |--duration-ms ] [--fps ] [--no-audio] [--out ] - Browser (clawd): - clawdis-mac browser status|start|stop|tabs|open|focus|close|screenshot|eval|query|dom|snapshot - UI Automation (Peekaboo): Install and use the `peekaboo` CLI; it will connect to Peekaboo.app (preferred) or Clawdis.app (fallback) via PeekabooBridge. See `docs/mac/peekaboo.md`. - Browser notes: - - Uses clawd’s dedicated Chrome/Chromium profile (separate user-data dir). - - Talks to the gateway’s loopback browser-control server (config: ~/.clawdis/clawdis.json). - - Keys: browser.enabled, browser.controlUrl (default: http://127.0.0.1:18791). - Examples: clawdis-mac status clawdis-mac agent --message "Hello from clawd" --thinking low - clawdis-mac browser start - clawdis-mac browser open https://example.com - clawdis-mac browser tabs - clawdis-mac browser screenshot --full-page - clawdis-mac browser eval \"location.href\" - clawdis-mac browser query \"a\" --limit 5 - clawdis-mac browser dom --format text --max-chars 5000 - clawdis-mac browser snapshot --format aria --limit 200 Output: Default output is text. Use --json for machine-readable output. - In text mode, `browser screenshot` prints MEDIA:. In text mode, `camera snap`, `camera clip`, and `screen record` print MEDIA:. """ print(usage) diff --git a/apps/macos/Tests/ClawdisCLITests/BrowserCLITests.swift b/apps/macos/Tests/ClawdisCLITests/BrowserCLITests.swift deleted file mode 100644 index 8cb42b2d9..000000000 --- a/apps/macos/Tests/ClawdisCLITests/BrowserCLITests.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Testing -@testable import ClawdisCLI - -@Suite struct BrowserCLITests { - @Test func tabsOutputIncludesFullTargetId() async throws { - let res: [String: Any] = [ - "running": true, - "tabs": [ - [ - "targetId": "57A01309E14B5DEE0FB41F908515A2FC", - "title": "Example", - "url": "https://example.com/", - ], - ], - ] - - let lines = BrowserCLI._testFormatTabs(res: res) - #expect(lines.contains(" id: 57A01309E14B5DEE0FB41F908515A2FC")) - } -}