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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE +21 -0
- data/README.md +141 -0
- data/exe/ask-computer +7 -0
- data/lib/ask/computer/app.rb +266 -0
- data/lib/ask/computer/cli.rb +138 -0
- data/lib/ask/computer/config.rb +22 -0
- data/lib/ask/computer/context.rb +32 -0
- data/lib/ask/computer/driver.rb +101 -0
- data/lib/ask/computer/errors.rb +45 -0
- data/lib/ask/computer/history.rb +204 -0
- data/lib/ask/computer/installer.rb +150 -0
- data/lib/ask/computer/sandbox.rb +84 -0
- data/lib/ask/computer/version.rb +7 -0
- data/lib/ask/computer.rb +70 -0
- data/lib/ask/skills/computer.use_computer/SKILL.md +121 -0
- data/lib/ask/skills/computer.use_computer/references/history.md +80 -0
- data/lib/ask/tools/computer/click.rb +27 -0
- data/lib/ask/tools/computer/history_query.rb +56 -0
- data/lib/ask/tools/computer/history_status.rb +36 -0
- data/lib/ask/tools/computer/installer_setup.rb +32 -0
- data/lib/ask/tools/computer/installer_status.rb +26 -0
- data/lib/ask/tools/computer/key.rb +23 -0
- data/lib/ask/tools/computer/screenshot.rb +22 -0
- data/lib/ask/tools/computer/type.rb +23 -0
- data/lib/ask-computer.rb +23 -0
- metadata +156 -0
|
@@ -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
|
data/lib/ask/computer.rb
ADDED
|
@@ -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
|