ask-computer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Computer
5
+ class Driver
6
+ attr_reader :config
7
+
8
+ def initialize(config = Ask::Computer.config)
9
+ @config = config
10
+ @client = nil
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def client
15
+ @mutex.synchronize { @client ||= build_client }
16
+ end
17
+
18
+ def start
19
+ client.start
20
+ self
21
+ rescue Errno::ENOENT, IOError => e
22
+ raise DriverNotAvailable,
23
+ "Cua Driver not found (#{config.driver_command}): #{e.message}. " \
24
+ "Install it: /bin/bash -c \"$(curl -fsSL https://cua.ai/driver/install.sh)\" — https://cua.ai/docs"
25
+ rescue StandardError => e
26
+ raise DriverNotAvailable, "Failed to start Cua Driver (#{config.driver_command}): #{e.message}"
27
+ end
28
+
29
+ def stop
30
+ @mutex.synchronize do
31
+ @client&.stop
32
+ @client = nil
33
+ end
34
+ end
35
+
36
+ def started?
37
+ !!(@client&.initialized?)
38
+ end
39
+
40
+ def tools
41
+ ensure_started
42
+ client.tools
43
+ end
44
+
45
+ def call_tool(name, arguments = {})
46
+ ensure_started
47
+ client.call_tool(name.to_s, arguments)
48
+ rescue DriverNotStarted
49
+ raise
50
+ rescue StandardError => e
51
+ if defined?(Ask::MCP::Error) && e.is_a?(Ask::MCP::Error)
52
+ raise map_mcp_error(e)
53
+ end
54
+ raise Ask::Computer::Error, e.message
55
+ end
56
+
57
+ def history_available?
58
+ ensure_started
59
+ names = client.tools.keys.map(&:to_s)
60
+ names.include?("history_status") || names.include?("history_query")
61
+ rescue Ask::Computer::Error
62
+ false
63
+ end
64
+
65
+ private
66
+
67
+ def ensure_started
68
+ raise DriverNotStarted, "Driver not started — call #start first" unless started?
69
+ end
70
+
71
+ def build_client
72
+ require "ask/mcp"
73
+ if config.driver_url?
74
+ Ask::MCP.from_http(config.driver_url)
75
+ else
76
+ Ask::MCP.from_stdio(config.driver_command, Array(config.driver_args))
77
+ end
78
+ end
79
+
80
+ def map_mcp_error(error)
81
+ message = error.message.to_s
82
+ code = extract_code(message)
83
+ return Ask::Computer::Error.from_code(code, message) if code
84
+
85
+ if message.match?(/permission|denied|unauthorized|forbidden/i)
86
+ PermissionDenied.new(message)
87
+ else
88
+ Ask::Computer::Error.new(message)
89
+ end
90
+ end
91
+
92
+ def extract_code(message)
93
+ if (m = message.match(/"code"\s*:\s*"(?<code>[^"]+)"/))
94
+ m[:code]
95
+ elsif (m = message.match(/\b(history_\w+|invalid_history\w*)\b/))
96
+ m[1]
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Computer
5
+ class Error < StandardError
6
+ CODE_MAP = {
7
+ "history_preview_not_admitted" => "HistoryNotAdmitted",
8
+ "history_key_locked" => "HistoryKeyLocked",
9
+ "history_key_unavailable" => "HistoryKeyUnavailable",
10
+ "history_key_corrupt" => "HistoryKeyCorrupt",
11
+ "history_key_destroy_failed" => "HistoryKeyCorrupt",
12
+ "history_storage_unavailable" => "HistoryStorageUnavailable",
13
+ "history_storage_corrupt" => "HistoryStorageCorrupt",
14
+ "history_quota_reached" => "HistoryQuotaReached",
15
+ "history_writer_stopped" => "HistoryWriterStopped",
16
+ "history_events_dropped" => "HistoryEventsDropped",
17
+ "invalid_history_query" => "InvalidHistoryQuery",
18
+ "invalid_history_query_range" => "InvalidHistoryQueryRange"
19
+ }.freeze
20
+
21
+ def self.from_code(code, message = nil)
22
+ klass_name = CODE_MAP[code.to_s]
23
+ klass = klass_name ? Ask::Computer.const_get(klass_name) : self
24
+ klass.new(message || code.to_s)
25
+ end
26
+ end
27
+ class DriverNotAvailable < Error; end
28
+ class DriverNotStarted < Error; end
29
+ class HistoryNotAvailable < Error; end
30
+ class HistoryNotAdmitted < Error; end
31
+ class HistoryKeyLocked < Error; end
32
+ class HistoryKeyUnavailable < Error; end
33
+ class HistoryKeyCorrupt < Error; end
34
+ class HistoryStorageUnavailable < Error; end
35
+ class HistoryStorageCorrupt < Error; end
36
+ class HistoryQuotaReached < Error; end
37
+ class HistoryWriterStopped < Error; end
38
+ class HistoryEventsDropped < Error; end
39
+ class InvalidHistoryQuery < Error; end
40
+ class InvalidHistoryQueryRange < Error; end
41
+ class PermissionDenied < Error; end
42
+
43
+ CODE_MAP = Error::CODE_MAP
44
+ end
45
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Computer
5
+ Status = Data.define(
6
+ :supported, :admitted, :enabled, :paused, :encrypted,
7
+ :profile, :retention_days, :quota_bytes, :bytes_used,
8
+ :dropped_events, :health, :raw
9
+ ) do
10
+ def enabled? = !!enabled
11
+ def paused? = !!paused
12
+ def healthy? = health.to_s == "ready"
13
+ def available? = supported && admitted && enabled && !paused? && healthy?
14
+ end
15
+
16
+ Event = Data.define(
17
+ :specversion, :id, :source, :type, :subject, :time,
18
+ :datacontenttype, :dataschema, :data, :raw
19
+ )
20
+
21
+ QueryResult = Data.define(:events, :metadata_only, :model_context_disclosure, :raw) do
22
+ def metadata_only? = !!metadata_only
23
+ end
24
+
25
+ class History
26
+ DEFAULT_LIMIT = 50
27
+ MAX_LIMIT = 200
28
+
29
+ def initialize(driver = nil)
30
+ @driver = driver || Ask::Computer.driver
31
+ end
32
+
33
+ def status
34
+ payload = call("history_status", {})
35
+ build_status(payload)
36
+ end
37
+
38
+ def query(limit: DEFAULT_LIMIT, session_id: nil, since_sequence: nil, until_sequence: nil)
39
+ args = {}
40
+ args[:limit] = Integer(limit) if limit
41
+ args[:session_id] = session_id if session_id
42
+ args[:since_sequence] = Integer(since_sequence) if since_sequence
43
+ args[:until_sequence] = Integer(until_sequence) if until_sequence
44
+
45
+ validate_query_args!(args)
46
+ payload = call("history_query", args)
47
+ build_result(payload)
48
+ end
49
+
50
+ def recent(limit: DEFAULT_LIMIT, session_id: nil)
51
+ query(limit: limit, session_id: session_id)
52
+ end
53
+
54
+ def each_page(limit: DEFAULT_LIMIT, session_id: nil)
55
+ return enum_for(:each_page, limit: limit, session_id: session_id) unless block_given?
56
+
57
+ cursor = nil
58
+ loop do
59
+ args = { limit: limit }
60
+ args[:session_id] = session_id if session_id
61
+ args[:until_sequence] = cursor if cursor
62
+ result = query(**args)
63
+ break if result.events.empty?
64
+
65
+ yield result
66
+ break if result.events.size < limit
67
+
68
+ earliest = result.events.map { |e| e.data[:sequence] || e.data["sequence"] }.compact.min
69
+ break unless earliest && earliest > 1
70
+
71
+ cursor = earliest - 1
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def call(name, args)
78
+ raw = @driver.call_tool(name, args)
79
+ payload = unwrap_content(raw)
80
+ if payload.is_a?(Array) && payload.first.is_a?(Hash)
81
+ text = payload.first[:text] || payload.first["text"]
82
+ raise HistoryNotAvailable, text if text.is_a?(String) && text.match?(/Unknown tool/i)
83
+ elsif payload.is_a?(Hash)
84
+ text = payload[:text] || payload["text"]
85
+ raise HistoryNotAvailable, text if text.is_a?(String) && text.match?(/Unknown tool/i)
86
+ end
87
+ payload
88
+ rescue Ask::Computer::Error
89
+ raise
90
+ rescue StandardError => e
91
+ code = extract_code(e.message)
92
+ raise Ask::Computer::Error.from_code(code, e.message) if code
93
+
94
+ raise Ask::Computer::Error, e.message
95
+ end
96
+
97
+ def unwrap_content(raw)
98
+ if raw.is_a?(Array)
99
+ if raw.first.is_a?(Hash)
100
+ text = raw.first[:text] || raw.first["text"]
101
+ if text.is_a?(String)
102
+ begin
103
+ parsed = JSON.parse(text, symbolize_names: true)
104
+ return parsed if parsed.is_a?(Hash)
105
+ rescue JSON::ParserError
106
+ end
107
+ end
108
+ end
109
+ return raw
110
+ end
111
+
112
+ return raw if raw.is_a?(Hash) && !raw.key?(:content) && !raw.key?("content")
113
+
114
+ content = raw[:content] || raw["content"] if raw.is_a?(Hash)
115
+ return raw unless content.is_a?(Array) && content.first
116
+
117
+ first = content.first
118
+ text = first[:text] || first["text"]
119
+ return raw unless text
120
+
121
+ JSON.parse(text, symbolize_names: true)
122
+ rescue JSON::ParserError
123
+ raw
124
+ end
125
+
126
+ def build_status(payload)
127
+ h = stringify(payload)
128
+ Status.new(
129
+ supported: !!h["supported"],
130
+ admitted: !!h["admitted"],
131
+ enabled: !!h["enabled"],
132
+ paused: !!h["paused"],
133
+ encrypted: !!h["encrypted"],
134
+ profile: h["profile"],
135
+ retention_days: h["retention_days"]&.to_i || 7,
136
+ quota_bytes: h["quota_bytes"]&.to_i || 104_857_600,
137
+ bytes_used: h["bytes_used"]&.to_i || 0,
138
+ dropped_events: h["dropped_events"]&.to_i || 0,
139
+ health: h["health"]&.to_s || "unknown",
140
+ raw: payload
141
+ )
142
+ end
143
+
144
+ def build_result(payload)
145
+ h = stringify(payload)
146
+ events = Array(h["events"]).map { |e| build_event(e) }
147
+ QueryResult.new(
148
+ events: events,
149
+ metadata_only: h["metadata_only"],
150
+ model_context_disclosure: h["model_context_disclosure"],
151
+ raw: payload
152
+ )
153
+ end
154
+
155
+ def build_event(hash)
156
+ h = stringify(hash)
157
+ Event.new(
158
+ specversion: h["specversion"],
159
+ id: h["id"],
160
+ source: h["source"],
161
+ type: h["type"],
162
+ subject: h["subject"],
163
+ time: h["time"],
164
+ datacontenttype: h["datacontenttype"],
165
+ dataschema: h["dataschema"],
166
+ data: h["data"] || {},
167
+ raw: hash
168
+ )
169
+ end
170
+
171
+ def stringify(obj)
172
+ return obj unless obj.is_a?(Hash)
173
+
174
+ obj.transform_keys(&:to_s)
175
+ end
176
+
177
+ def validate_query_args!(args)
178
+ if args[:limit] && (args[:limit] < 1 || args[:limit] > MAX_LIMIT)
179
+ raise InvalidHistoryQuery, "limit must be 1..#{MAX_LIMIT} (got #{args[:limit]})"
180
+ end
181
+ if args[:session_id] && (args[:session_id].to_s.empty? || args[:session_id].to_s.length > 128)
182
+ raise InvalidHistoryQuery, "session_id must be 1..128 characters"
183
+ end
184
+ if args[:since_sequence] && args[:since_sequence] < 1
185
+ raise InvalidHistoryQuery, "since_sequence must be >= 1"
186
+ end
187
+ if args[:until_sequence] && args[:until_sequence] < 1
188
+ raise InvalidHistoryQuery, "until_sequence must be >= 1"
189
+ end
190
+ if args[:since_sequence] && args[:until_sequence] && args[:since_sequence] > args[:until_sequence]
191
+ raise InvalidHistoryQueryRange, "since_sequence (#{args[:since_sequence]}) must not exceed until_sequence (#{args[:until_sequence]})"
192
+ end
193
+ end
194
+
195
+ def extract_code(message)
196
+ if (m = message.match(/"code"\s*:\s*"(?<code>[^"]+)"/))
197
+ m[:code]
198
+ elsif (m = message.match(/\b(history_\w+|invalid_history\w*)\b/))
199
+ m[1]
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Ask
6
+ module Computer
7
+ class Installer
8
+ INSTALL_URL = "https://cua.ai/driver/install.sh"
9
+ UNINSTALL_URL = "https://cua.ai/driver/uninstall.sh"
10
+
11
+ Result = Data.define(:ok, :command, :stdout, :stderr, :exit_code, :message) do
12
+ def ok? = !!ok
13
+ def success? = ok?
14
+ end
15
+
16
+ class << self
17
+ def installed?
18
+ !which(cua_bin).nil?
19
+ end
20
+
21
+ def version
22
+ out, _err, status = Open3.capture3(cua_bin, "--version")
23
+ return nil unless status.success?
24
+ out.strip
25
+ rescue StandardError
26
+ nil
27
+ end
28
+
29
+ def history_status
30
+ out, err, status = Open3.capture3(cua_bin, "history", "status", "--json")
31
+ raw = out.empty? ? err : out
32
+ return Result.new(ok: false, command: "#{cua_bin} history status", stdout: out, stderr: err, exit_code: status.exitstatus, message: raw.strip) unless status.success?
33
+ Result.new(ok: true, command: "#{cua_bin} history status", stdout: out, stderr: err, exit_code: 0, message: out.strip)
34
+ rescue StandardError => e
35
+ Result.new(ok: false, command: "#{cua_bin} history status", stdout: "", stderr: e.message, exit_code: 1, message: e.message)
36
+ end
37
+
38
+ def install(channel: "stable", bin_dir: nil, no_modify_path: false, dry_run: false)
39
+ args = []
40
+ args += ["--channel", channel.to_s] if channel
41
+ args += ["--bin-dir", bin_dir] if bin_dir && !bin_dir.empty?
42
+ args << "--no-modify-path" if no_modify_path
43
+
44
+ cmd = build_install_command(args)
45
+ return Result.new(ok: true, command: cmd, stdout: "", stderr: "", exit_code: 0, message: "dry run: #{cmd}") if dry_run
46
+
47
+ run_shell(cmd)
48
+ end
49
+
50
+ def enable_history
51
+ run_cua("history", "enable")
52
+ end
53
+
54
+ def disable_history
55
+ run_cua("history", "disable")
56
+ end
57
+
58
+ def history_enable_result
59
+ enable_history
60
+ end
61
+
62
+ def uninstall(purge: false)
63
+ args = purge ? ["--purge"] : []
64
+ cmd = "/bin/bash -c \"$(curl -fsSL #{UNINSTALL_URL})\"#{args.empty? ? "" : " -- #{args.join(" ")}"}"
65
+ run_shell(cmd)
66
+ end
67
+
68
+ def status
69
+ {
70
+ installed: installed?,
71
+ version: installed? ? version : nil,
72
+ bin: which(cua_bin),
73
+ history: history_status_payload
74
+ }
75
+ end
76
+
77
+ def setup(channel: "stable", enable_history: false, bin_dir: nil, no_modify_path: false, dry_run: false)
78
+ steps = []
79
+ unless installed?
80
+ r = install(channel: channel, bin_dir: bin_dir, no_modify_path: no_modify_path, dry_run: dry_run)
81
+ steps << [:install, r]
82
+ return steps unless r.ok?
83
+ else
84
+ steps << [:install, Result.new(ok: true, command: "already installed", stdout: "", stderr: "", exit_code: 0, message: "cua-driver already installed (#{version})")]
85
+ end
86
+
87
+ if enable_history
88
+ if dry_run
89
+ r = Result.new(ok: true, command: "#{cua_bin} history enable", stdout: "", stderr: "", exit_code: 0, message: "dry run: #{cua_bin} history enable")
90
+ else
91
+ r = enable_history
92
+ end
93
+ steps << [:enable_history, r]
94
+ end
95
+
96
+ steps
97
+ end
98
+
99
+ private
100
+
101
+ def cua_bin
102
+ Ask::Computer.config.driver_command
103
+ end
104
+
105
+ def which(bin)
106
+ out, _err, status = Open3.capture3("which", bin)
107
+ return nil unless status.success?
108
+ path = out.strip
109
+ path.empty? ? nil : path
110
+ rescue StandardError
111
+ nil
112
+ end
113
+
114
+ def build_install_command(extra_args)
115
+ arg_str = extra_args.empty? ? "" : " -- #{extra_args.join(" ")}"
116
+ "/bin/bash -c \"$(curl -fsSL #{INSTALL_URL})\"#{arg_str}"
117
+ end
118
+
119
+ def run_shell(cmd)
120
+ out, err, status = Open3.capture3("/bin/bash", "-c", cmd)
121
+ ok = status.success?
122
+ msg = ok ? out.strip : (err.strip.empty? ? out.strip : err.strip)
123
+ Result.new(ok: ok, command: cmd, stdout: out, stderr: err, exit_code: status.exitstatus, message: msg)
124
+ rescue StandardError => e
125
+ Result.new(ok: false, command: cmd, stdout: "", stderr: e.message, exit_code: 1, message: e.message)
126
+ end
127
+
128
+ def run_cua(*args)
129
+ out, err, status = Open3.capture3(cua_bin, *args)
130
+ ok = status.success?
131
+ msg = ok ? out.strip : (err.strip.empty? ? out.strip : err.strip)
132
+ Result.new(ok: ok, command: "#{cua_bin} #{args.join(" ")}", stdout: out, stderr: err, exit_code: status.exitstatus, message: msg)
133
+ rescue StandardError => e
134
+ Result.new(ok: false, command: "#{cua_bin} #{args.join(" ")}", stdout: "", stderr: e.message, exit_code: 1, message: e.message)
135
+ end
136
+
137
+ def history_status_payload
138
+ r = history_status
139
+ return { ok: false, message: r.message } unless r.ok?
140
+ begin
141
+ require "json"
142
+ JSON.parse(r.stdout)
143
+ rescue StandardError
144
+ { ok: true, raw: r.stdout }
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Computer
5
+ class Sandbox
6
+ attr_reader :driver
7
+
8
+ def initialize(driver = nil)
9
+ @driver = driver || Ask::Computer.driver
10
+ end
11
+
12
+ def screenshot
13
+ result = @driver.call_tool("computer_screenshot", {})
14
+ extract_image(result)
15
+ end
16
+
17
+ def ephemeral(image = "linux", &block)
18
+ started = ensure_sandbox_tool?
19
+ raise DriverNotAvailable, "Sandbox not available on this driver" unless started
20
+
21
+ result = @driver.call_tool("sandbox_create", { image: image.to_s })
22
+ id = result["sandbox_id"] || result[:sandbox_id] || result.dig("content", 0, "text")
23
+ handle = Handle.new(id.to_s, @driver)
24
+ return handle unless block
25
+
26
+ begin
27
+ yield handle
28
+ ensure
29
+ handle.destroy
30
+ end
31
+ end
32
+
33
+ class Handle
34
+ attr_reader :id, :driver
35
+
36
+ def initialize(id, driver)
37
+ @id = id
38
+ @driver = driver
39
+ end
40
+
41
+ def screenshot
42
+ @driver.call_tool("sandbox_screenshot", { sandbox_id: id })
43
+ end
44
+
45
+ def click(x, y)
46
+ @driver.call_tool("sandbox_click", { sandbox_id: id, x: Integer(x), y: Integer(y) })
47
+ end
48
+
49
+ def type(text)
50
+ @driver.call_tool("sandbox_type", { sandbox_id: id, text: text.to_s })
51
+ end
52
+
53
+ def shell(command)
54
+ @driver.call_tool("sandbox_shell", { sandbox_id: id, command: command.to_s })
55
+ end
56
+
57
+ def destroy
58
+ @driver.call_tool("sandbox_destroy", { sandbox_id: id })
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def ensure_sandbox_tool?
67
+ tools = @driver.tools
68
+ tools.key?("sandbox_create") || tools.key?(:sandbox_create)
69
+ rescue StandardError
70
+ false
71
+ end
72
+
73
+ def extract_image(result)
74
+ return result if result.is_a?(Hash) && (result["type"] == "image" || result[:type] == "image")
75
+
76
+ content = result[:content] || result["content"]
77
+ return result unless content.is_a?(Array)
78
+
79
+ image = content.find { |c| (c[:type] || c["type"]) == "image" }
80
+ image || result
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Computer
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "computer/version"
4
+ require_relative "computer/errors"
5
+ require_relative "computer/config"
6
+ require_relative "computer/context"
7
+
8
+ module Ask
9
+ module Computer
10
+ class << self
11
+ def config
12
+ @config ||= Config.new
13
+ end
14
+
15
+ def configure
16
+ yield config if block_given?
17
+ config
18
+ end
19
+
20
+ def reset!
21
+ @config = Config.new
22
+ @driver = nil
23
+ @history = nil
24
+ @sandbox = nil
25
+ end
26
+
27
+ def driver
28
+ @driver ||= Driver.new(config)
29
+ end
30
+
31
+ def driver=(instance)
32
+ @driver = instance
33
+ end
34
+
35
+ def history(driver = nil)
36
+ History.new(driver || self.driver)
37
+ end
38
+
39
+ def sandbox(driver = nil)
40
+ Sandbox.new(driver || self.driver)
41
+ end
42
+
43
+ # Launch (or find) a desktop app and resolve its live window.
44
+ #
45
+ # app = Ask::Computer.launch("com.apple.calculator")
46
+ # app.press("7")
47
+ #
48
+ def launch(bundle_id_or_name, driver: nil)
49
+ App.launch(bundle_id_or_name, driver: driver || self.driver)
50
+ end
51
+
52
+ def installer
53
+ Installer
54
+ end
55
+
56
+ def installed? = Installer.installed?
57
+ def install(...) = Installer.install(...)
58
+ def setup(...) = Installer.setup(...)
59
+ def status = Installer.status
60
+ end
61
+
62
+ autoload :Driver, "ask/computer/driver"
63
+ autoload :App, "ask/computer/app"
64
+ autoload :EmptyAccessibilityTree, "ask/computer/app"
65
+ autoload :History, "ask/computer/history"
66
+ autoload :Sandbox, "ask/computer/sandbox"
67
+ autoload :Installer, "ask/computer/installer"
68
+ autoload :CLI, "ask/computer/cli"
69
+ end
70
+ end