exa-ai-ruby 1.0.0 → 1.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 +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +79 -16
- data/exe/exa +14 -0
- data/lib/exa/cli/account_resolver.rb +53 -0
- data/lib/exa/cli/config_store.rb +97 -0
- data/lib/exa/cli/root.rb +802 -0
- data/lib/exa/cli.rb +5 -0
- data/lib/exa/internal/transport/base_client.rb +49 -9
- data/lib/exa/internal/transport/pooled_net_requester.rb +10 -1
- data/lib/exa/resources/search.rb +21 -1
- data/lib/exa/resources/websets.rb +17 -0
- data/lib/exa/responses/answer_response.rb +77 -0
- data/lib/exa/responses/search_response.rb +15 -2
- data/lib/exa/responses.rb +1 -0
- data/lib/exa/types/answer.rb +1 -0
- data/lib/exa/version.rb +1 -1
- metadata +93 -2
data/lib/exa/cli/root.rb
ADDED
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "exa"
|
|
6
|
+
require_relative "config_store"
|
|
7
|
+
require_relative "account_resolver"
|
|
8
|
+
|
|
9
|
+
module Exa
|
|
10
|
+
module CLI
|
|
11
|
+
class Root < Thor
|
|
12
|
+
def self.exit_on_failure?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
COLON_COMMANDS = %w[
|
|
17
|
+
accounts:list
|
|
18
|
+
accounts:add
|
|
19
|
+
accounts:use
|
|
20
|
+
accounts:remove
|
|
21
|
+
search:run
|
|
22
|
+
search:contents
|
|
23
|
+
search:similar
|
|
24
|
+
search:answer
|
|
25
|
+
research:create
|
|
26
|
+
research:list
|
|
27
|
+
research:get
|
|
28
|
+
research:cancel
|
|
29
|
+
websets:create
|
|
30
|
+
websets:list
|
|
31
|
+
websets:get
|
|
32
|
+
websets:update
|
|
33
|
+
websets:delete
|
|
34
|
+
websets:cancel
|
|
35
|
+
websets:preview
|
|
36
|
+
websets:items:list
|
|
37
|
+
websets:items:get
|
|
38
|
+
websets:items:delete
|
|
39
|
+
websets:enrichments:create
|
|
40
|
+
websets:enrichments:get
|
|
41
|
+
websets:enrichments:update
|
|
42
|
+
websets:enrichments:delete
|
|
43
|
+
websets:enrichments:cancel
|
|
44
|
+
monitors:create
|
|
45
|
+
monitors:list
|
|
46
|
+
monitors:get
|
|
47
|
+
monitors:update
|
|
48
|
+
monitors:delete
|
|
49
|
+
monitors:runs:list
|
|
50
|
+
monitors:runs:get
|
|
51
|
+
imports:create
|
|
52
|
+
imports:list
|
|
53
|
+
imports:get
|
|
54
|
+
imports:update
|
|
55
|
+
imports:delete
|
|
56
|
+
events:list
|
|
57
|
+
events:get
|
|
58
|
+
webhooks:list
|
|
59
|
+
webhooks:create
|
|
60
|
+
webhooks:get
|
|
61
|
+
webhooks:update
|
|
62
|
+
webhooks:delete
|
|
63
|
+
webhooks:attempts
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
COLON_COMMANDS.each { |label| map label => label.tr(":", "_").to_sym }
|
|
67
|
+
|
|
68
|
+
class_option :account, type: :string, desc: "Use a named account from your exa config"
|
|
69
|
+
class_option :api_key, type: :string, desc: "Override the API key for this invocation"
|
|
70
|
+
class_option :base_url, type: :string, desc: "Override the API base URL"
|
|
71
|
+
class_option :config, type: :string, desc: "Path to the exa CLI config file"
|
|
72
|
+
class_option :format, type: :string, default: "table", desc: "Output format: table, json, or raw"
|
|
73
|
+
|
|
74
|
+
# Version -----------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
desc "version", "Print the CLI and gem version"
|
|
77
|
+
def version
|
|
78
|
+
say "exa-ai-ruby #{Exa::VERSION}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Account management ------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
desc "accounts:list", "List stored accounts"
|
|
84
|
+
option :json, type: :boolean, default: false, desc: "Emit JSON instead of a table"
|
|
85
|
+
def accounts_list
|
|
86
|
+
data = config_store.read
|
|
87
|
+
if options[:json]
|
|
88
|
+
say JSON.pretty_generate(data)
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
if data["accounts"].empty?
|
|
93
|
+
say "No accounts configured. Add one with `exa accounts:add NAME --api-key ...`."
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
data["accounts"].each do |name, account|
|
|
98
|
+
marker = data["default"] == name ? "*" : " "
|
|
99
|
+
say "#{marker} #{name.ljust(12)} #{account['base_url']}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
desc "accounts:add NAME", "Add or update an account credential"
|
|
104
|
+
option :api_key, type: :string, required: true, desc: "API key to store"
|
|
105
|
+
option :base_url, type: :string, default: AccountResolver::DEFAULT_BASE_URL, desc: "API base URL"
|
|
106
|
+
option :default, type: :boolean, default: true, desc: "Set as the default account"
|
|
107
|
+
def accounts_add(name)
|
|
108
|
+
config_store.upsert_account(
|
|
109
|
+
name,
|
|
110
|
+
api_key: options[:api_key],
|
|
111
|
+
base_url: options[:base_url],
|
|
112
|
+
make_default: options[:default]
|
|
113
|
+
)
|
|
114
|
+
config_store.set_default(name) if options[:default]
|
|
115
|
+
say "Saved account '#{name}'."
|
|
116
|
+
rescue ConfigStore::UnknownAccountError => e
|
|
117
|
+
raise Thor::Error, e.message
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
desc "accounts:use NAME", "Set the default account"
|
|
121
|
+
def accounts_use(name)
|
|
122
|
+
config_store.set_default(name)
|
|
123
|
+
say "Default account set to '#{name}'."
|
|
124
|
+
rescue ConfigStore::UnknownAccountError => e
|
|
125
|
+
raise Thor::Error, e.message
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
desc "accounts:remove NAME", "Delete a stored account"
|
|
129
|
+
option :yes, type: :boolean, default: false, desc: "Confirm deletion without prompting"
|
|
130
|
+
def accounts_remove(name)
|
|
131
|
+
unless options[:yes]
|
|
132
|
+
raise Thor::Error, "Pass --yes to confirm account deletion."
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
removed = config_store.remove_account(name)
|
|
136
|
+
if removed
|
|
137
|
+
say "Removed account '#{name}'."
|
|
138
|
+
else
|
|
139
|
+
raise Thor::Error, "Account '#{name}' not found."
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Search ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
desc "search:run QUERY", "Run a search query against the Exa API"
|
|
146
|
+
option :num_results, type: :numeric, desc: "Number of results to return"
|
|
147
|
+
option :text, type: :boolean, default: false, desc: "Include page text in the response"
|
|
148
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
149
|
+
def search_run(query)
|
|
150
|
+
payload = { query: query }
|
|
151
|
+
payload[:num_results] = options[:num_results].to_i if options[:num_results]
|
|
152
|
+
payload[:text] = true if options[:text]
|
|
153
|
+
response = client.search.search(payload)
|
|
154
|
+
render_response(response, json: options[:json])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
desc "search:contents", "Fetch contents for specific URLs"
|
|
158
|
+
option :urls, type: :string, desc: "Comma-separated list of URLs"
|
|
159
|
+
option :file, type: :string, desc: "File containing URLs (one per line)"
|
|
160
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
161
|
+
def search_contents
|
|
162
|
+
urls = []
|
|
163
|
+
urls.concat(split_list(options[:urls])) if options[:urls]
|
|
164
|
+
urls.concat(read_urls_from_file(options[:file])) if options[:file]
|
|
165
|
+
urls.map!(&:strip)
|
|
166
|
+
urls.reject!(&:empty?)
|
|
167
|
+
urls.uniq!
|
|
168
|
+
|
|
169
|
+
if urls.empty?
|
|
170
|
+
raise Thor::Error, "Provide URLs via --urls or --file."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
response = client.search.contents(urls: urls)
|
|
174
|
+
render_response(response, json: options[:json])
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
desc "search:similar", "Find similar documents by id or URL"
|
|
178
|
+
option :id, type: :string, desc: "Existing search result id"
|
|
179
|
+
option :url, type: :string, desc: "URL to match"
|
|
180
|
+
option :num_results, type: :numeric, desc: "Number of results to return"
|
|
181
|
+
option :text, type: :boolean, default: false, desc: "Include page text"
|
|
182
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
183
|
+
def search_similar
|
|
184
|
+
params = {}
|
|
185
|
+
params[:id] = options[:id] if options[:id]
|
|
186
|
+
params[:url] = options[:url] if options[:url]
|
|
187
|
+
params[:num_results] = options[:num_results].to_i if options[:num_results]
|
|
188
|
+
params[:text] = true if options[:text]
|
|
189
|
+
if params[:id].nil? && params[:url].nil?
|
|
190
|
+
raise Thor::Error, "Provide --id or --url."
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
response = client.search.find_similar(params)
|
|
194
|
+
render_response(response, json: options[:json])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
desc "search:answer QUERY", "Call the /answer endpoint"
|
|
198
|
+
option :search_options, type: :string, desc: "JSON blob or @file with search options"
|
|
199
|
+
option :schema, type: :string, desc: "JSON schema (inline or @file) for structured summary"
|
|
200
|
+
option :stream, type: :boolean, default: false, desc: "Stream results as server-sent events"
|
|
201
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON/events"
|
|
202
|
+
def search_answer(query)
|
|
203
|
+
payload = { query: query }
|
|
204
|
+
if (opts = parse_json_option(options[:search_options], flag: "--search-options"))
|
|
205
|
+
payload[:search_options] = opts
|
|
206
|
+
end
|
|
207
|
+
if (schema = parse_json_option(options[:schema], flag: "--schema"))
|
|
208
|
+
payload[:summary] = { schema: schema }
|
|
209
|
+
end
|
|
210
|
+
payload[:stream] = true if options[:stream]
|
|
211
|
+
|
|
212
|
+
response = client.search.answer(payload)
|
|
213
|
+
if options[:stream]
|
|
214
|
+
render_stream(response, json: options[:json])
|
|
215
|
+
else
|
|
216
|
+
render_response(response, json: options[:json])
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Research ----------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
desc "research:create", "Create a research run"
|
|
223
|
+
option :instructions, type: :string, required: true, desc: "Research instructions"
|
|
224
|
+
option :model, type: :string, desc: "Model name"
|
|
225
|
+
option :schema, type: :string, desc: "JSON schema for output"
|
|
226
|
+
option :events, type: :boolean, default: false, desc: "Request event stream"
|
|
227
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
228
|
+
def research_create
|
|
229
|
+
payload = {
|
|
230
|
+
instructions: options[:instructions]
|
|
231
|
+
}
|
|
232
|
+
payload[:model] = options[:model] if options[:model]
|
|
233
|
+
if (schema = parse_json_option(options[:schema], flag: "--schema"))
|
|
234
|
+
payload[:output_schema] = schema
|
|
235
|
+
end
|
|
236
|
+
payload[:events] = true if options[:events]
|
|
237
|
+
|
|
238
|
+
response = client.research.create(payload)
|
|
239
|
+
render_response(response, json: options[:json])
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
desc "research:list", "List research runs"
|
|
243
|
+
option :status, type: :string, desc: "Filter by status"
|
|
244
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
245
|
+
option :limit, type: :numeric, desc: "Max results to return"
|
|
246
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
247
|
+
def research_list
|
|
248
|
+
params = compact_hash(
|
|
249
|
+
status: options[:status],
|
|
250
|
+
cursor: options[:cursor],
|
|
251
|
+
limit: options[:limit]&.to_i
|
|
252
|
+
)
|
|
253
|
+
response = client.research.list(params.empty? ? nil : params)
|
|
254
|
+
render_response(response, json: options[:json])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
desc "research:get ID", "Fetch a research run"
|
|
258
|
+
option :events, type: :boolean, default: false, desc: "Include events in the response"
|
|
259
|
+
option :stream, type: :boolean, default: false, desc: "Stream updates"
|
|
260
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
261
|
+
def research_get(id)
|
|
262
|
+
response = client.research.get(id, events: options[:events], stream: options[:stream])
|
|
263
|
+
if options[:stream]
|
|
264
|
+
render_stream(response, json: options[:json])
|
|
265
|
+
else
|
|
266
|
+
render_response(response, json: options[:json])
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
desc "research:cancel ID [ID...]", "Cancel one or more research runs"
|
|
271
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
272
|
+
def research_cancel(*ids)
|
|
273
|
+
if ids.empty?
|
|
274
|
+
raise Thor::Error, "Provide at least one research id."
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
ids.each do |research_id|
|
|
278
|
+
response = client.research.cancel(research_id)
|
|
279
|
+
render_response(response, json: options[:json])
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Websets -----------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
desc "websets:create", "Create a webset"
|
|
286
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file containing create params"
|
|
287
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
288
|
+
def websets_create
|
|
289
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
290
|
+
response = client.websets.create(payload)
|
|
291
|
+
render_response(response, json: options[:json])
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
desc "websets:list", "List websets"
|
|
295
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
296
|
+
option :limit, type: :numeric, desc: "Limit page size"
|
|
297
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
298
|
+
def websets_list
|
|
299
|
+
params = compact_hash(
|
|
300
|
+
cursor: options[:cursor],
|
|
301
|
+
limit: options[:limit]&.to_i
|
|
302
|
+
)
|
|
303
|
+
response = client.websets.list(params.empty? ? nil : params)
|
|
304
|
+
render_response(response, json: options[:json])
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
desc "websets:get ID", "Retrieve a single webset"
|
|
308
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
309
|
+
def websets_get(id)
|
|
310
|
+
response = client.websets.retrieve(id)
|
|
311
|
+
render_response(response, json: options[:json])
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
desc "websets:update ID", "Update a webset"
|
|
315
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
316
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
317
|
+
def websets_update(id)
|
|
318
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
319
|
+
response = client.websets.update(id, payload)
|
|
320
|
+
render_response(response, json: options[:json])
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
desc "websets:delete ID", "Delete a webset"
|
|
324
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
325
|
+
def websets_delete(id)
|
|
326
|
+
response = client.websets.delete(id)
|
|
327
|
+
render_response(response, json: options[:json])
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
desc "websets:cancel ID", "Cancel all searches/enrichments for a webset"
|
|
331
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
332
|
+
def websets_cancel(id)
|
|
333
|
+
response = client.websets.cancel(id)
|
|
334
|
+
render_response(response, json: options[:json])
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
desc "websets:preview", "Preview changes to a webset definition"
|
|
338
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
339
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
340
|
+
def websets_preview
|
|
341
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
342
|
+
response = client.websets.preview(payload)
|
|
343
|
+
render_response(response, json: options[:json])
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Webset items ------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
desc "websets:items:list WEBSET_ID", "List items belonging to a webset"
|
|
349
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
350
|
+
option :limit, type: :numeric, desc: "Limit"
|
|
351
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
352
|
+
def websets_items_list(webset_id)
|
|
353
|
+
params = compact_hash(
|
|
354
|
+
cursor: options[:cursor],
|
|
355
|
+
limit: options[:limit]&.to_i
|
|
356
|
+
)
|
|
357
|
+
response = client.websets.items.list(webset_id, params.empty? ? nil : params)
|
|
358
|
+
render_response(response, json: options[:json])
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
desc "websets:items:get WEBSET_ID ITEM_ID", "Retrieve a webset item"
|
|
362
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
363
|
+
def websets_items_get(webset_id, item_id)
|
|
364
|
+
response = client.websets.items.retrieve(webset_id, item_id)
|
|
365
|
+
render_response(response, json: options[:json])
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
desc "websets:items:delete WEBSET_ID ITEM_ID", "Delete an item"
|
|
369
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
370
|
+
def websets_items_delete(webset_id, item_id)
|
|
371
|
+
response = client.websets.items.delete(webset_id, item_id)
|
|
372
|
+
render_response(response, json: options[:json])
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Webset enrichments ------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
desc "websets:enrichments:create WEBSET_ID", "Create an enrichment"
|
|
378
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
379
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
380
|
+
def websets_enrichments_create(webset_id)
|
|
381
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
382
|
+
response = client.websets.enrichments.create(webset_id, payload)
|
|
383
|
+
render_response(response, json: options[:json])
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
desc "websets:enrichments:get WEBSET_ID ENRICHMENT_ID", "Retrieve enrichment details"
|
|
387
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
388
|
+
def websets_enrichments_get(webset_id, enrichment_id)
|
|
389
|
+
response = client.websets.enrichments.retrieve(webset_id, enrichment_id)
|
|
390
|
+
render_response(response, json: options[:json])
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
desc "websets:enrichments:update WEBSET_ID ENRICHMENT_ID", "Update an enrichment"
|
|
394
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
395
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
396
|
+
def websets_enrichments_update(webset_id, enrichment_id)
|
|
397
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
398
|
+
response = client.websets.enrichments.update(webset_id, enrichment_id, payload)
|
|
399
|
+
render_response(response, json: options[:json])
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
desc "websets:enrichments:delete WEBSET_ID ENRICHMENT_ID", "Delete an enrichment"
|
|
403
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
404
|
+
def websets_enrichments_delete(webset_id, enrichment_id)
|
|
405
|
+
response = client.websets.enrichments.delete(webset_id, enrichment_id)
|
|
406
|
+
render_response(response, json: options[:json])
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
desc "websets:enrichments:cancel WEBSET_ID ENRICHMENT_ID", "Cancel an enrichment"
|
|
410
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
411
|
+
def websets_enrichments_cancel(webset_id, enrichment_id)
|
|
412
|
+
response = client.websets.enrichments.cancel(webset_id, enrichment_id)
|
|
413
|
+
render_response(response, json: options[:json])
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Monitors ----------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
desc "monitors:create", "Create a monitor"
|
|
419
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
420
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
421
|
+
def monitors_create
|
|
422
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
423
|
+
response = client.websets.monitors.create(payload)
|
|
424
|
+
render_response(response, json: options[:json])
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
desc "monitors:list", "List monitors"
|
|
428
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
429
|
+
option :limit, type: :numeric, desc: "Limit page size"
|
|
430
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
431
|
+
def monitors_list
|
|
432
|
+
params = compact_hash(
|
|
433
|
+
cursor: options[:cursor],
|
|
434
|
+
limit: options[:limit]&.to_i
|
|
435
|
+
)
|
|
436
|
+
response = client.websets.monitors.list(params.empty? ? nil : params)
|
|
437
|
+
render_response(response, json: options[:json])
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
desc "monitors:get ID", "Retrieve a monitor"
|
|
441
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
442
|
+
def monitors_get(id)
|
|
443
|
+
response = client.websets.monitors.retrieve(id)
|
|
444
|
+
render_response(response, json: options[:json])
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
desc "monitors:update ID", "Update a monitor"
|
|
448
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
449
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
450
|
+
def monitors_update(id)
|
|
451
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
452
|
+
response = client.websets.monitors.update(id, payload)
|
|
453
|
+
render_response(response, json: options[:json])
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
desc "monitors:delete ID", "Delete a monitor"
|
|
457
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
458
|
+
def monitors_delete(id)
|
|
459
|
+
response = client.websets.monitors.delete(id)
|
|
460
|
+
render_response(response, json: options[:json])
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
desc "monitors:runs:list ID", "List monitor runs"
|
|
464
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
465
|
+
option :limit, type: :numeric, desc: "Limit page size"
|
|
466
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
467
|
+
def monitors_runs_list(monitor_id)
|
|
468
|
+
params = compact_hash(
|
|
469
|
+
cursor: options[:cursor],
|
|
470
|
+
limit: options[:limit]&.to_i
|
|
471
|
+
)
|
|
472
|
+
response = client.websets.monitors.runs_list(monitor_id, params.empty? ? nil : params)
|
|
473
|
+
render_response(response, json: options[:json])
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
desc "monitors:runs:get MONITOR_ID RUN_ID", "Get a specific monitor run"
|
|
477
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
478
|
+
def monitors_runs_get(monitor_id, run_id)
|
|
479
|
+
response = client.websets.monitors.runs_get(monitor_id, run_id)
|
|
480
|
+
render_response(response, json: options[:json])
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Imports -----------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
desc "imports:create", "Create an import"
|
|
486
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
487
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
488
|
+
def imports_create
|
|
489
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
490
|
+
response = client.imports.create(payload)
|
|
491
|
+
render_response(response, json: options[:json])
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
desc "imports:list", "List imports"
|
|
495
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
496
|
+
option :limit, type: :numeric, desc: "Limit page size"
|
|
497
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
498
|
+
def imports_list
|
|
499
|
+
params = compact_hash(
|
|
500
|
+
cursor: options[:cursor],
|
|
501
|
+
limit: options[:limit]&.to_i
|
|
502
|
+
)
|
|
503
|
+
response = client.imports.list(params.empty? ? nil : params)
|
|
504
|
+
render_response(response, json: options[:json])
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
desc "imports:get ID", "Retrieve an import"
|
|
508
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
509
|
+
def imports_get(id)
|
|
510
|
+
response = client.imports.retrieve(id)
|
|
511
|
+
render_response(response, json: options[:json])
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
desc "imports:update ID", "Update an import"
|
|
515
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
516
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
517
|
+
def imports_update(id)
|
|
518
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
519
|
+
response = client.imports.update(id, payload)
|
|
520
|
+
render_response(response, json: options[:json])
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
desc "imports:delete ID", "Delete an import"
|
|
524
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
525
|
+
def imports_delete(id)
|
|
526
|
+
response = client.imports.delete(id)
|
|
527
|
+
render_response(response, json: options[:json])
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# Events ------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
desc "events:list", "List events"
|
|
533
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
534
|
+
option :limit, type: :numeric, desc: "Limit"
|
|
535
|
+
option :types, type: :string, desc: "Comma separated event types"
|
|
536
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
537
|
+
def events_list
|
|
538
|
+
params = compact_hash(
|
|
539
|
+
cursor: options[:cursor],
|
|
540
|
+
limit: options[:limit]&.to_i,
|
|
541
|
+
types: options[:types] ? split_list(options[:types]) : nil
|
|
542
|
+
)
|
|
543
|
+
response = client.events.list(params.empty? ? nil : params)
|
|
544
|
+
render_response(response, json: options[:json])
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
desc "events:get ID", "Retrieve an event"
|
|
548
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
549
|
+
def events_get(id)
|
|
550
|
+
response = client.events.retrieve(id)
|
|
551
|
+
render_response(response, json: options[:json])
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Webhooks ----------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
desc "webhooks:list", "List webhooks"
|
|
557
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
558
|
+
option :limit, type: :numeric, desc: "Limit"
|
|
559
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
560
|
+
def webhooks_list
|
|
561
|
+
params = compact_hash(
|
|
562
|
+
cursor: options[:cursor],
|
|
563
|
+
limit: options[:limit]&.to_i
|
|
564
|
+
)
|
|
565
|
+
response = client.webhooks.list(params.empty? ? nil : params)
|
|
566
|
+
render_response(response, json: options[:json])
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
desc "webhooks:create", "Create a webhook"
|
|
570
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
571
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
572
|
+
def webhooks_create
|
|
573
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
574
|
+
response = client.webhooks.create(payload)
|
|
575
|
+
render_response(response, json: options[:json])
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
desc "webhooks:get ID", "Retrieve a webhook"
|
|
579
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
580
|
+
def webhooks_get(id)
|
|
581
|
+
response = client.webhooks.retrieve(id)
|
|
582
|
+
render_response(response, json: options[:json])
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
desc "webhooks:update ID", "Update a webhook"
|
|
586
|
+
option :data, type: :string, required: true, desc: "JSON payload or @file"
|
|
587
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
588
|
+
def webhooks_update(id)
|
|
589
|
+
payload = parse_required_json_option!(options[:data], flag: "--data")
|
|
590
|
+
response = client.webhooks.update(id, payload)
|
|
591
|
+
render_response(response, json: options[:json])
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
desc "webhooks:delete ID", "Delete a webhook"
|
|
595
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
596
|
+
def webhooks_delete(id)
|
|
597
|
+
response = client.webhooks.delete(id)
|
|
598
|
+
render_response(response, json: options[:json])
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
desc "webhooks:attempts ID", "List webhook delivery attempts"
|
|
602
|
+
option :cursor, type: :string, desc: "Pagination cursor"
|
|
603
|
+
option :limit, type: :numeric, desc: "Limit"
|
|
604
|
+
option :json, type: :boolean, default: false, desc: "Emit raw JSON"
|
|
605
|
+
def webhooks_attempts(id)
|
|
606
|
+
params = compact_hash(
|
|
607
|
+
cursor: options[:cursor],
|
|
608
|
+
limit: options[:limit]&.to_i
|
|
609
|
+
)
|
|
610
|
+
response = client.webhooks.attempts(id, params.empty? ? nil : params)
|
|
611
|
+
render_response(response, json: options[:json])
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Helpers -----------------------------------------------------------------
|
|
615
|
+
|
|
616
|
+
no_commands do
|
|
617
|
+
def config_store
|
|
618
|
+
@config_store ||= Exa::CLI::ConfigStore.new(path: options[:config])
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def account_resolver
|
|
622
|
+
@account_resolver ||= Exa::CLI::AccountResolver.new(config_store: config_store)
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def client
|
|
626
|
+
@client ||= begin
|
|
627
|
+
credentials = account_resolver.resolve(options: options, env: ENV)
|
|
628
|
+
Exa::Client.new(api_key: credentials.api_key, base_url: credentials.base_url)
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def render_response(response, json:, collection_accessor: nil)
|
|
633
|
+
payload = serializable(response)
|
|
634
|
+
if json
|
|
635
|
+
say JSON.pretty_generate(payload)
|
|
636
|
+
return
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
collection = extract_collection(response, payload, collection_accessor)
|
|
640
|
+
if collection
|
|
641
|
+
if collection.empty?
|
|
642
|
+
say "No results."
|
|
643
|
+
else
|
|
644
|
+
collection.each_with_index do |item, index|
|
|
645
|
+
say format_collection_entry(item, index)
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
return
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
say format_single_entry(payload)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def render_stream(stream, json:)
|
|
655
|
+
if json
|
|
656
|
+
stream.each do |chunk|
|
|
657
|
+
say chunk.to_s
|
|
658
|
+
end
|
|
659
|
+
return
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
if stream.respond_to?(:each_event)
|
|
663
|
+
stream.each_event do |event|
|
|
664
|
+
say format_stream_event(event)
|
|
665
|
+
end
|
|
666
|
+
else
|
|
667
|
+
stream.each { |chunk| say chunk.to_s }
|
|
668
|
+
end
|
|
669
|
+
ensure
|
|
670
|
+
stream.close if stream.respond_to?(:close)
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def format_stream_event(event)
|
|
674
|
+
label = event[:event] || "event"
|
|
675
|
+
data = event[:data]
|
|
676
|
+
if data.nil? || data.empty?
|
|
677
|
+
"[#{label}]"
|
|
678
|
+
else
|
|
679
|
+
"[#{label}] #{data}"
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
def format_collection_entry(item, index)
|
|
684
|
+
id = value_from(item, :id)
|
|
685
|
+
primary = value_from(item, :title) ||
|
|
686
|
+
value_from(item, :name) ||
|
|
687
|
+
value_from(item, :url) ||
|
|
688
|
+
value_from(item, :status) ||
|
|
689
|
+
id ||
|
|
690
|
+
item.to_s
|
|
691
|
+
suffix = id && primary != id ? " (#{id})" : ""
|
|
692
|
+
"#{index + 1}. #{primary}#{suffix}"
|
|
693
|
+
end
|
|
694
|
+
|
|
695
|
+
def format_single_entry(payload)
|
|
696
|
+
id = value_from(payload, :id)
|
|
697
|
+
title = value_from(payload, :title) ||
|
|
698
|
+
value_from(payload, :name) ||
|
|
699
|
+
value_from(payload, :url) ||
|
|
700
|
+
value_from(payload, :status)
|
|
701
|
+
return "#{id} - #{title}" if id && title
|
|
702
|
+
return id.to_s if id
|
|
703
|
+
return title.to_s if title
|
|
704
|
+
|
|
705
|
+
payload.inspect
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def extract_collection(response, payload, accessor)
|
|
709
|
+
accessor ||= if response.respond_to?(:results)
|
|
710
|
+
:results
|
|
711
|
+
elsif response.respond_to?(:data)
|
|
712
|
+
:data
|
|
713
|
+
elsif payload.is_a?(Hash) && payload["results"]
|
|
714
|
+
"results"
|
|
715
|
+
elsif payload.is_a?(Hash) && payload["data"]
|
|
716
|
+
"data"
|
|
717
|
+
end
|
|
718
|
+
return nil unless accessor
|
|
719
|
+
|
|
720
|
+
if response.respond_to?(accessor)
|
|
721
|
+
Array(response.public_send(accessor))
|
|
722
|
+
elsif payload.is_a?(Hash)
|
|
723
|
+
Array(payload[accessor.to_s] || payload[accessor.to_sym])
|
|
724
|
+
end
|
|
725
|
+
end
|
|
726
|
+
|
|
727
|
+
def split_list(value)
|
|
728
|
+
return [] if value.nil? || value.empty?
|
|
729
|
+
value.split(",").map(&:strip)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
def read_urls_from_file(path)
|
|
733
|
+
return [] unless path
|
|
734
|
+
|
|
735
|
+
File.read(File.expand_path(path)).lines
|
|
736
|
+
rescue Errno::ENOENT
|
|
737
|
+
raise Thor::Error, "File not found: #{path}"
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
def serializable(object)
|
|
741
|
+
case object
|
|
742
|
+
when nil, Numeric, String, TrueClass, FalseClass
|
|
743
|
+
object
|
|
744
|
+
when Array
|
|
745
|
+
object.map { |item| serializable(item) }
|
|
746
|
+
when Hash
|
|
747
|
+
object.transform_values { |value| serializable(value) }
|
|
748
|
+
else
|
|
749
|
+
if object.respond_to?(:serialize)
|
|
750
|
+
serializable(object.serialize)
|
|
751
|
+
elsif object.respond_to?(:to_hash)
|
|
752
|
+
serializable(object.to_hash)
|
|
753
|
+
else
|
|
754
|
+
object
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
def value_from(result, key)
|
|
760
|
+
if result.respond_to?(key)
|
|
761
|
+
result.public_send(key)
|
|
762
|
+
elsif result.is_a?(Hash)
|
|
763
|
+
result[key.to_s] || result[key]
|
|
764
|
+
end
|
|
765
|
+
end
|
|
766
|
+
|
|
767
|
+
def parse_required_json_option!(value, flag:)
|
|
768
|
+
parsed = parse_json_option(value, flag: flag)
|
|
769
|
+
return parsed unless parsed.nil?
|
|
770
|
+
|
|
771
|
+
raise Thor::Error, "Provide #{flag} with a JSON payload or @file."
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
def parse_json_option(value, flag:)
|
|
775
|
+
return nil if value.nil?
|
|
776
|
+
|
|
777
|
+
content = if value.start_with?("@")
|
|
778
|
+
path = File.expand_path(value.delete_prefix("@"))
|
|
779
|
+
File.read(path)
|
|
780
|
+
else
|
|
781
|
+
value
|
|
782
|
+
end
|
|
783
|
+
JSON.parse(content)
|
|
784
|
+
rescue Errno::ENOENT
|
|
785
|
+
raise Thor::Error, "File not found for #{flag}: #{value}"
|
|
786
|
+
rescue JSON::ParserError => e
|
|
787
|
+
raise Thor::Error, "Invalid JSON for #{flag}: #{e.message}"
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def compact_hash(hash)
|
|
791
|
+
hash.each_with_object({}) do |(key, val), acc|
|
|
792
|
+
next if val.nil?
|
|
793
|
+
if val.respond_to?(:empty?) && val.empty?
|
|
794
|
+
next
|
|
795
|
+
end
|
|
796
|
+
acc[key] = val
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
end
|