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.
@@ -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