exa-ai-ruby 1.0.0 → 1.1.1

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