exaonruby 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +614 -0
- data/exaonruby.gemspec +37 -0
- data/exe/exa +7 -0
- data/lib/exa/cli.rb +458 -0
- data/lib/exa/client.rb +210 -0
- data/lib/exa/configuration.rb +81 -0
- data/lib/exa/endpoints/answer.rb +109 -0
- data/lib/exa/endpoints/contents.rb +141 -0
- data/lib/exa/endpoints/events.rb +71 -0
- data/lib/exa/endpoints/find_similar.rb +154 -0
- data/lib/exa/endpoints/imports.rb +145 -0
- data/lib/exa/endpoints/monitors.rb +193 -0
- data/lib/exa/endpoints/research.rb +158 -0
- data/lib/exa/endpoints/search.rb +195 -0
- data/lib/exa/endpoints/webhooks.rb +161 -0
- data/lib/exa/endpoints/webset_enrichments.rb +162 -0
- data/lib/exa/endpoints/webset_items.rb +90 -0
- data/lib/exa/endpoints/webset_searches.rb +137 -0
- data/lib/exa/endpoints/websets.rb +214 -0
- data/lib/exa/errors.rb +180 -0
- data/lib/exa/resources/answer_response.rb +101 -0
- data/lib/exa/resources/base.rb +56 -0
- data/lib/exa/resources/contents_response.rb +123 -0
- data/lib/exa/resources/event.rb +84 -0
- data/lib/exa/resources/import.rb +137 -0
- data/lib/exa/resources/monitor.rb +205 -0
- data/lib/exa/resources/paginated_response.rb +87 -0
- data/lib/exa/resources/research_task.rb +165 -0
- data/lib/exa/resources/search_response.rb +111 -0
- data/lib/exa/resources/search_result.rb +95 -0
- data/lib/exa/resources/webhook.rb +152 -0
- data/lib/exa/resources/webset.rb +491 -0
- data/lib/exa/resources/webset_item.rb +256 -0
- data/lib/exa/utils/parameter_converter.rb +159 -0
- data/lib/exa/utils/webhook_handler.rb +239 -0
- data/lib/exa/version.rb +7 -0
- data/lib/exa.rb +130 -0
- metadata +146 -0
data/lib/exa/cli.rb
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
require "thor"
|
|
6
|
+
require "json"
|
|
7
|
+
|
|
8
|
+
module Exa
|
|
9
|
+
# Beautiful command-line interface for the Exa.ai API
|
|
10
|
+
#
|
|
11
|
+
# Features:
|
|
12
|
+
# - Neural search from the terminal
|
|
13
|
+
# - Answer questions instantly
|
|
14
|
+
# - Manage Websets and enrichments
|
|
15
|
+
# - Monitor research tasks
|
|
16
|
+
# - Colorful, informative output
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage
|
|
19
|
+
# $ exa search "latest AI research"
|
|
20
|
+
# $ exa answer "What is the valuation of SpaceX?"
|
|
21
|
+
# $ exa websets list
|
|
22
|
+
class CLI < Thor
|
|
23
|
+
class_option :api_key, type: :string, aliases: "-k", desc: "API key (or set EXA_API_KEY)"
|
|
24
|
+
class_option :json, type: :boolean, aliases: "-j", desc: "Output raw JSON"
|
|
25
|
+
class_option :no_color, type: :boolean, desc: "Disable colored output"
|
|
26
|
+
|
|
27
|
+
def self.exit_on_failure?
|
|
28
|
+
true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
32
|
+
# SEARCH COMMANDS
|
|
33
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
34
|
+
|
|
35
|
+
desc "search QUERY", "Search the web using Exa's neural search"
|
|
36
|
+
method_option :num, type: :numeric, default: 10, aliases: "-n", desc: "Number of results"
|
|
37
|
+
method_option :type, type: :string, default: "auto", desc: "Search type: auto, neural, fast, deep"
|
|
38
|
+
method_option :text, type: :boolean, default: false, aliases: "-t", desc: "Include text content"
|
|
39
|
+
method_option :summary, type: :boolean, default: false, aliases: "-s", desc: "Include AI summary"
|
|
40
|
+
method_option :domain, type: :string, aliases: "-d", desc: "Filter by domain"
|
|
41
|
+
method_option :category, type: :string, aliases: "-c", desc: "Category filter"
|
|
42
|
+
def search(query)
|
|
43
|
+
with_client do |client|
|
|
44
|
+
opts = {
|
|
45
|
+
num_results: options[:num],
|
|
46
|
+
type: options[:type].to_sym,
|
|
47
|
+
text: options[:text],
|
|
48
|
+
summary: options[:summary]
|
|
49
|
+
}
|
|
50
|
+
opts[:include_domains] = [options[:domain]] if options[:domain]
|
|
51
|
+
opts[:category] = options[:category].to_sym if options[:category]
|
|
52
|
+
|
|
53
|
+
response = client.search(query, **opts)
|
|
54
|
+
|
|
55
|
+
if options[:json]
|
|
56
|
+
puts JSON.pretty_generate(response.raw)
|
|
57
|
+
else
|
|
58
|
+
print_search_results(response, query)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
desc "answer QUESTION", "Get an AI-powered answer with citations"
|
|
64
|
+
method_option :text, type: :boolean, default: true, aliases: "-t", desc: "Include source text"
|
|
65
|
+
def answer(question)
|
|
66
|
+
with_client do |client|
|
|
67
|
+
response = client.answer(question, text: options[:text])
|
|
68
|
+
|
|
69
|
+
if options[:json]
|
|
70
|
+
puts JSON.pretty_generate(response.raw)
|
|
71
|
+
else
|
|
72
|
+
print_answer(response, question)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
desc "similar URL", "Find pages similar to a URL"
|
|
78
|
+
method_option :num, type: :numeric, default: 10, aliases: "-n", desc: "Number of results"
|
|
79
|
+
method_option :text, type: :boolean, default: false, aliases: "-t", desc: "Include text content"
|
|
80
|
+
def similar(url)
|
|
81
|
+
with_client do |client|
|
|
82
|
+
response = client.find_similar(url, num_results: options[:num], text: options[:text])
|
|
83
|
+
|
|
84
|
+
if options[:json]
|
|
85
|
+
puts JSON.pretty_generate(response.raw)
|
|
86
|
+
else
|
|
87
|
+
print_search_results(response, "Similar to: #{url}")
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
desc "contents URLS...", "Fetch full page contents from URLs"
|
|
93
|
+
method_option :summary, type: :boolean, default: false, aliases: "-s", desc: "Include AI summary"
|
|
94
|
+
def contents(*urls)
|
|
95
|
+
with_client do |client|
|
|
96
|
+
response = client.get_contents(urls, text: true, summary: options[:summary])
|
|
97
|
+
|
|
98
|
+
if options[:json]
|
|
99
|
+
puts JSON.pretty_generate(response.raw)
|
|
100
|
+
else
|
|
101
|
+
print_contents(response)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
107
|
+
# RESEARCH COMMANDS
|
|
108
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
109
|
+
|
|
110
|
+
desc "research INSTRUCTIONS", "Start an async research task"
|
|
111
|
+
method_option :model, type: :string, default: "exa-research", desc: "Model: exa-research-fast, exa-research, exa-research-pro"
|
|
112
|
+
method_option :wait, type: :boolean, default: false, aliases: "-w", desc: "Wait for completion"
|
|
113
|
+
def research(instructions)
|
|
114
|
+
with_client do |client|
|
|
115
|
+
task = client.create_research(instructions: instructions, model: options[:model])
|
|
116
|
+
|
|
117
|
+
if options[:wait]
|
|
118
|
+
print_waiting_for_research(task)
|
|
119
|
+
task = wait_for_research(client, task.research_id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
if options[:json]
|
|
123
|
+
puts JSON.pretty_generate(task.raw)
|
|
124
|
+
else
|
|
125
|
+
print_research_task(task)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
desc "research_status ID", "Get status of a research task"
|
|
131
|
+
def research_status(research_id)
|
|
132
|
+
with_client do |client|
|
|
133
|
+
task = client.get_research(research_id)
|
|
134
|
+
|
|
135
|
+
if options[:json]
|
|
136
|
+
puts JSON.pretty_generate(task.raw)
|
|
137
|
+
else
|
|
138
|
+
print_research_task(task)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
144
|
+
# WEBSET COMMANDS
|
|
145
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
146
|
+
|
|
147
|
+
desc "websets SUBCOMMAND", "Manage Websets"
|
|
148
|
+
subcommand "websets", Class.new(Thor) {
|
|
149
|
+
class_option :api_key, type: :string, aliases: "-k", desc: "API key (or set EXA_API_KEY)"
|
|
150
|
+
class_option :json, type: :boolean, aliases: "-j", desc: "Output raw JSON"
|
|
151
|
+
class_option :no_color, type: :boolean, desc: "Disable colored output"
|
|
152
|
+
|
|
153
|
+
desc "list", "List all Websets"
|
|
154
|
+
method_option :limit, type: :numeric, default: 20, aliases: "-n", desc: "Max results"
|
|
155
|
+
def list
|
|
156
|
+
with_client do |client|
|
|
157
|
+
response = client.list_websets(limit: options[:limit])
|
|
158
|
+
|
|
159
|
+
if options[:json]
|
|
160
|
+
puts JSON.pretty_generate(response.raw)
|
|
161
|
+
else
|
|
162
|
+
print_websets_list(response)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
desc "get ID", "Get a Webset by ID"
|
|
168
|
+
def get(id)
|
|
169
|
+
with_client do |client|
|
|
170
|
+
webset = client.get_webset(id)
|
|
171
|
+
|
|
172
|
+
if options[:json]
|
|
173
|
+
puts JSON.pretty_generate(webset.raw)
|
|
174
|
+
else
|
|
175
|
+
print_webset(webset)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
desc "items ID", "List items in a Webset"
|
|
181
|
+
method_option :limit, type: :numeric, default: 20, aliases: "-n", desc: "Max results"
|
|
182
|
+
def items(id)
|
|
183
|
+
with_client do |client|
|
|
184
|
+
response = client.list_webset_items(id, limit: options[:limit])
|
|
185
|
+
|
|
186
|
+
if options[:json]
|
|
187
|
+
puts JSON.pretty_generate(response.raw)
|
|
188
|
+
else
|
|
189
|
+
print_webset_items(response)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
desc "delete ID", "Delete a Webset"
|
|
195
|
+
def delete(id)
|
|
196
|
+
with_client do |client|
|
|
197
|
+
client.delete_webset(id)
|
|
198
|
+
puts color("✓ Webset #{id} deleted", :green)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
private
|
|
203
|
+
|
|
204
|
+
def with_client
|
|
205
|
+
api_key = options[:api_key] || ENV["EXA_API_KEY"]
|
|
206
|
+
raise Thor::Error, "API key required. Set EXA_API_KEY or use --api-key" unless api_key
|
|
207
|
+
|
|
208
|
+
client = Exa::Client.new(api_key: api_key)
|
|
209
|
+
yield client
|
|
210
|
+
rescue Exa::Error => e
|
|
211
|
+
raise Thor::Error, color("✗ #{e.message}", :red)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def color(text, color_name)
|
|
215
|
+
return text if options[:no_color]
|
|
216
|
+
|
|
217
|
+
colors = {
|
|
218
|
+
red: "\e[31m",
|
|
219
|
+
green: "\e[32m",
|
|
220
|
+
yellow: "\e[33m",
|
|
221
|
+
blue: "\e[34m",
|
|
222
|
+
magenta: "\e[35m",
|
|
223
|
+
cyan: "\e[36m",
|
|
224
|
+
white: "\e[37m",
|
|
225
|
+
bold: "\e[1m",
|
|
226
|
+
dim: "\e[2m",
|
|
227
|
+
reset: "\e[0m"
|
|
228
|
+
}
|
|
229
|
+
"#{colors[color_name]}#{text}#{colors[:reset]}"
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def print_websets_list(response)
|
|
233
|
+
header("📊 Websets (#{response.data.length})")
|
|
234
|
+
response.data.each do |ws|
|
|
235
|
+
status_color = ws.status == "idle" ? :green : :yellow
|
|
236
|
+
puts " #{color(ws.id, :cyan)} #{color(ws.status, status_color)}"
|
|
237
|
+
puts " #{color(ws.title || "Untitled", :white)}" if ws.title
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def print_webset(webset)
|
|
242
|
+
header("📊 Webset: #{webset.id}")
|
|
243
|
+
puts " #{color("Status:", :dim)} #{webset.status}"
|
|
244
|
+
puts " #{color("Title:", :dim)} #{webset.title}" if webset.title
|
|
245
|
+
puts " #{color("Created:", :dim)} #{webset.created_at}"
|
|
246
|
+
puts ""
|
|
247
|
+
if webset.searches && !webset.searches.empty?
|
|
248
|
+
puts " #{color("Searches:", :bold)}"
|
|
249
|
+
webset.searches.each do |s|
|
|
250
|
+
puts " #{color("•", :cyan)} #{s.query} (#{s.status})"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def print_webset_items(response)
|
|
256
|
+
header("📋 Items (#{response.data.length})")
|
|
257
|
+
response.data.each_with_index do |item, i|
|
|
258
|
+
puts " #{color("[#{i + 1}]", :cyan)} #{item.url}"
|
|
259
|
+
if item.properties
|
|
260
|
+
props = item.properties
|
|
261
|
+
puts " #{color("Title:", :dim)} #{props.title}" if props.title
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def header(text)
|
|
267
|
+
puts ""
|
|
268
|
+
puts color("═" * 60, :dim)
|
|
269
|
+
puts color(" #{text}", :bold)
|
|
270
|
+
puts color("═" * 60, :dim)
|
|
271
|
+
puts ""
|
|
272
|
+
end
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
276
|
+
# VERSION AND INFO
|
|
277
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
278
|
+
|
|
279
|
+
desc "version", "Show version information"
|
|
280
|
+
def version
|
|
281
|
+
puts ""
|
|
282
|
+
puts color(" ╔═══════════════════════════════════════╗", :cyan)
|
|
283
|
+
puts color(" ║", :cyan) + color(" EXA RUBY CLI", :bold) + color(" ║", :cyan)
|
|
284
|
+
puts color(" ║", :cyan) + " Version #{Exa::VERSION} " + color("║", :cyan)
|
|
285
|
+
puts color(" ╚═══════════════════════════════════════╝", :cyan)
|
|
286
|
+
puts ""
|
|
287
|
+
puts " #{color("API:", :dim)} https://api.exa.ai"
|
|
288
|
+
puts " #{color("Docs:", :dim)} https://docs.exa.ai"
|
|
289
|
+
puts ""
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
desc "info", "Show current configuration"
|
|
293
|
+
def info
|
|
294
|
+
api_key = options[:api_key] || ENV["EXA_API_KEY"]
|
|
295
|
+
|
|
296
|
+
puts ""
|
|
297
|
+
header("⚙️ Configuration")
|
|
298
|
+
puts " #{color("API Key:", :dim)} #{api_key ? "#{api_key[0..7]}..." : color("Not set", :red)}"
|
|
299
|
+
puts " #{color("Environment:", :dim)} #{ENV["EXA_API_KEY"] ? "EXA_API_KEY set" : "Not set"}"
|
|
300
|
+
puts ""
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def with_client
|
|
306
|
+
api_key = options[:api_key] || ENV["EXA_API_KEY"]
|
|
307
|
+
raise Thor::Error, "API key required. Set EXA_API_KEY or use --api-key" unless api_key
|
|
308
|
+
|
|
309
|
+
client = Client.new(api_key: api_key)
|
|
310
|
+
yield client
|
|
311
|
+
rescue Error => e
|
|
312
|
+
raise Thor::Error, color("✗ #{e.message}", :red)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def color(text, color_name)
|
|
316
|
+
return text if options[:no_color]
|
|
317
|
+
|
|
318
|
+
colors = {
|
|
319
|
+
red: "\e[31m",
|
|
320
|
+
green: "\e[32m",
|
|
321
|
+
yellow: "\e[33m",
|
|
322
|
+
blue: "\e[34m",
|
|
323
|
+
magenta: "\e[35m",
|
|
324
|
+
cyan: "\e[36m",
|
|
325
|
+
white: "\e[37m",
|
|
326
|
+
bold: "\e[1m",
|
|
327
|
+
dim: "\e[2m",
|
|
328
|
+
reset: "\e[0m"
|
|
329
|
+
}
|
|
330
|
+
"#{colors[color_name]}#{text}#{colors[:reset]}"
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def header(text)
|
|
334
|
+
puts ""
|
|
335
|
+
puts color("═" * 60, :dim)
|
|
336
|
+
puts color(" #{text}", :bold)
|
|
337
|
+
puts color("═" * 60, :dim)
|
|
338
|
+
puts ""
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def print_search_results(response, query)
|
|
342
|
+
header("🔍 Search: \"#{query}\"")
|
|
343
|
+
puts " #{color("Results:", :dim)} #{response.count} #{color("Cost:", :dim)} $#{response.total_cost.round(4)}"
|
|
344
|
+
puts ""
|
|
345
|
+
|
|
346
|
+
response.results.each_with_index do |result, i|
|
|
347
|
+
puts " #{color("[#{i + 1}]", :cyan)} #{color(result.title || "Untitled", :bold)}"
|
|
348
|
+
puts " #{color(result.url, :blue)}"
|
|
349
|
+
if result.published_date
|
|
350
|
+
puts " #{color("Published:", :dim)} #{result.published_date.strftime("%Y-%m-%d")}"
|
|
351
|
+
end
|
|
352
|
+
if result.text
|
|
353
|
+
snippet = result.text[0..200].gsub(/\s+/, " ").strip
|
|
354
|
+
puts " #{color(snippet, :dim)}..."
|
|
355
|
+
end
|
|
356
|
+
if result.summary
|
|
357
|
+
puts " #{color("Summary:", :green)} #{result.summary[0..150]}..."
|
|
358
|
+
end
|
|
359
|
+
puts ""
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def print_answer(response, question)
|
|
364
|
+
header("💡 Answer")
|
|
365
|
+
puts " #{color("Q:", :cyan)} #{question}"
|
|
366
|
+
puts ""
|
|
367
|
+
puts " #{color("A:", :green)} #{response.answer}"
|
|
368
|
+
puts ""
|
|
369
|
+
|
|
370
|
+
if response.citations.any?
|
|
371
|
+
puts " #{color("Sources:", :bold)}"
|
|
372
|
+
response.citations.each_with_index do |citation, i|
|
|
373
|
+
puts " #{color("[#{i + 1}]", :cyan)} #{citation.title}"
|
|
374
|
+
puts " #{color(citation.url, :blue)}"
|
|
375
|
+
end
|
|
376
|
+
puts ""
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
puts " #{color("Cost:", :dim)} $#{response.total_cost.round(4)}"
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def print_contents(response)
|
|
383
|
+
header("📄 Contents (#{response.results.length} pages)")
|
|
384
|
+
|
|
385
|
+
response.results.each_with_index do |page, i|
|
|
386
|
+
puts " #{color("[#{i + 1}]", :cyan)} #{color(page.title || "Untitled", :bold)}"
|
|
387
|
+
puts " #{color(page.url, :blue)}"
|
|
388
|
+
if page.text
|
|
389
|
+
puts ""
|
|
390
|
+
lines = page.text.split("\n").map(&:strip).reject(&:empty?).first(10)
|
|
391
|
+
lines.each { |line| puts " #{color(line[0..80], :dim)}" }
|
|
392
|
+
puts " ..."
|
|
393
|
+
end
|
|
394
|
+
if page.summary
|
|
395
|
+
puts ""
|
|
396
|
+
puts " #{color("Summary:", :green)} #{page.summary[0..200]}..."
|
|
397
|
+
end
|
|
398
|
+
puts ""
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def print_research_task(task)
|
|
403
|
+
status_colors = {
|
|
404
|
+
"pending" => :yellow,
|
|
405
|
+
"running" => :cyan,
|
|
406
|
+
"completed" => :green,
|
|
407
|
+
"failed" => :red,
|
|
408
|
+
"canceled" => :dim
|
|
409
|
+
}
|
|
410
|
+
status_color = status_colors[task.status] || :white
|
|
411
|
+
|
|
412
|
+
header("🔬 Research Task")
|
|
413
|
+
puts " #{color("ID:", :dim)} #{task.research_id}"
|
|
414
|
+
puts " #{color("Status:", :dim)} #{color(task.status.upcase, status_color)}"
|
|
415
|
+
puts " #{color("Model:", :dim)} #{task.model}"
|
|
416
|
+
|
|
417
|
+
if task.progress
|
|
418
|
+
progress_bar = "▓" * (task.progress * 20).round + "░" * (20 - (task.progress * 20).round)
|
|
419
|
+
puts " #{color("Progress:", :dim)} [#{color(progress_bar, :cyan)}] #{(task.progress * 100).round}%"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
if task.output
|
|
423
|
+
puts ""
|
|
424
|
+
puts " #{color("Output:", :bold)}"
|
|
425
|
+
output_lines = task.output.to_s.split("\n").first(20)
|
|
426
|
+
output_lines.each { |line| puts " #{line[0..70]}" }
|
|
427
|
+
puts " ..." if task.output.to_s.split("\n").length > 20
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
if task.error_message
|
|
431
|
+
puts ""
|
|
432
|
+
puts " #{color("Error:", :red)} #{task.error_message}"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def print_waiting_for_research(task)
|
|
437
|
+
puts " #{color("⏳ Waiting for research to complete...", :yellow)}"
|
|
438
|
+
puts " #{color("ID:", :dim)} #{task.research_id}"
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def wait_for_research(client, research_id)
|
|
442
|
+
loop do
|
|
443
|
+
task = client.get_research(research_id)
|
|
444
|
+
|
|
445
|
+
case task.status
|
|
446
|
+
when "completed", "failed", "canceled"
|
|
447
|
+
return task
|
|
448
|
+
when "running"
|
|
449
|
+
if task.progress
|
|
450
|
+
print "\r #{color("Progress:", :dim)} #{(task.progress * 100).round}% "
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
|
|
454
|
+
sleep 3
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
data/lib/exa/client.rb
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# typed: strict
|
|
4
|
+
|
|
5
|
+
require "faraday"
|
|
6
|
+
require "faraday/retry"
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Exa
|
|
10
|
+
class Client
|
|
11
|
+
include Endpoints::Search
|
|
12
|
+
include Endpoints::Contents
|
|
13
|
+
include Endpoints::FindSimilar
|
|
14
|
+
include Endpoints::Answer
|
|
15
|
+
include Endpoints::Research
|
|
16
|
+
include Endpoints::Websets
|
|
17
|
+
include Endpoints::WebsetItems
|
|
18
|
+
include Endpoints::WebsetSearches
|
|
19
|
+
include Endpoints::WebsetEnrichments
|
|
20
|
+
include Endpoints::Monitors
|
|
21
|
+
include Endpoints::Imports
|
|
22
|
+
include Endpoints::Webhooks
|
|
23
|
+
include Endpoints::Events
|
|
24
|
+
|
|
25
|
+
# @return [Configuration] Client configuration
|
|
26
|
+
attr_reader :config
|
|
27
|
+
|
|
28
|
+
# Creates a new Exa API client
|
|
29
|
+
#
|
|
30
|
+
# @param api_key [String, nil] API key (defaults to EXA_API_KEY env var)
|
|
31
|
+
# @param base_url [String, nil] Base URL for Search API
|
|
32
|
+
# @param websets_base_url [String, nil] Base URL for Websets API
|
|
33
|
+
# @param timeout [Integer, nil] Request timeout in seconds
|
|
34
|
+
# @param max_retries [Integer, nil] Maximum retry attempts
|
|
35
|
+
#
|
|
36
|
+
# @yield [Configuration] Optional configuration block
|
|
37
|
+
#
|
|
38
|
+
# @example Create client with API key
|
|
39
|
+
# client = Exa::Client.new(api_key: "your-api-key")
|
|
40
|
+
#
|
|
41
|
+
# @example Create client with configuration block
|
|
42
|
+
# client = Exa::Client.new do |config|
|
|
43
|
+
# config.api_key = "your-api-key"
|
|
44
|
+
# config.timeout = 120
|
|
45
|
+
# end
|
|
46
|
+
def initialize(api_key: nil, base_url: nil, websets_base_url: nil, timeout: nil, max_retries: nil)
|
|
47
|
+
@config = Configuration.new
|
|
48
|
+
|
|
49
|
+
@config.api_key = api_key if api_key
|
|
50
|
+
@config.base_url = base_url if base_url
|
|
51
|
+
@config.websets_base_url = websets_base_url if websets_base_url
|
|
52
|
+
@config.timeout = timeout if timeout
|
|
53
|
+
@config.max_retries = max_retries if max_retries
|
|
54
|
+
|
|
55
|
+
yield @config if block_given?
|
|
56
|
+
|
|
57
|
+
@config.validate!
|
|
58
|
+
|
|
59
|
+
@connection = build_connection(@config.base_url)
|
|
60
|
+
@websets_connection = build_connection(@config.websets_base_url)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Builds a Faraday connection with retry middleware
|
|
66
|
+
# @param base_url [String] Base URL for the connection
|
|
67
|
+
# @return [Faraday::Connection]
|
|
68
|
+
def build_connection(base_url)
|
|
69
|
+
Faraday.new(url: base_url) do |conn|
|
|
70
|
+
conn.request :json
|
|
71
|
+
conn.response :json, content_type: /\bjson$/
|
|
72
|
+
|
|
73
|
+
conn.request :retry, {
|
|
74
|
+
max: config.max_retries,
|
|
75
|
+
interval: config.retry_delay,
|
|
76
|
+
max_interval: config.max_retry_delay,
|
|
77
|
+
backoff_factor: 2,
|
|
78
|
+
retry_statuses: config.retry_statuses,
|
|
79
|
+
exceptions: config.retry_exceptions,
|
|
80
|
+
retry_block: ->(env, options, retries, exc) {
|
|
81
|
+
Exa.logger&.warn("Retrying request (attempt #{retries + 1}): #{exc.message}")
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
conn.options.timeout = config.timeout
|
|
86
|
+
conn.options.open_timeout = 10
|
|
87
|
+
|
|
88
|
+
conn.adapter Faraday.default_adapter
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Makes a POST request to the Search API
|
|
93
|
+
# @param path [String] API endpoint path
|
|
94
|
+
# @param body [Hash] Request body
|
|
95
|
+
# @return [Hash] Parsed response
|
|
96
|
+
def post(path, body)
|
|
97
|
+
response = @connection.post(path) do |req|
|
|
98
|
+
req.headers["x-api-key"] = config.api_key
|
|
99
|
+
req.headers["Content-Type"] = "application/json"
|
|
100
|
+
req.body = body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
handle_response(response)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Makes a GET request to the Search API
|
|
107
|
+
# @param path [String] API endpoint path
|
|
108
|
+
# @param params [Hash] Query parameters
|
|
109
|
+
# @return [Hash] Parsed response
|
|
110
|
+
def get(path, params = {})
|
|
111
|
+
response = @connection.get(path) do |req|
|
|
112
|
+
req.headers["x-api-key"] = config.api_key
|
|
113
|
+
req.params = params unless params.empty?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
handle_response(response)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Makes a POST request to the Websets API
|
|
120
|
+
# @param path [String] API endpoint path
|
|
121
|
+
# @param body [Hash] Request body
|
|
122
|
+
# @return [Hash] Parsed response
|
|
123
|
+
def websets_post(path, body)
|
|
124
|
+
response = @websets_connection.post(path) do |req|
|
|
125
|
+
req.headers["x-api-key"] = config.api_key
|
|
126
|
+
req.headers["Content-Type"] = "application/json"
|
|
127
|
+
req.body = body
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
handle_response(response)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Makes a GET request to the Websets API
|
|
134
|
+
# @param path [String] API endpoint path
|
|
135
|
+
# @param params [Hash] Query parameters
|
|
136
|
+
# @return [Hash] Parsed response
|
|
137
|
+
def websets_get(path, params = {})
|
|
138
|
+
response = @websets_connection.get(path) do |req|
|
|
139
|
+
req.headers["x-api-key"] = config.api_key
|
|
140
|
+
req.params = params unless params.empty?
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
handle_response(response)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Makes a DELETE request to the Websets API
|
|
147
|
+
# @param path [String] API endpoint path
|
|
148
|
+
# @return [Hash] Parsed response
|
|
149
|
+
def websets_delete(path)
|
|
150
|
+
response = @websets_connection.delete(path) do |req|
|
|
151
|
+
req.headers["x-api-key"] = config.api_key
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
handle_response(response)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Makes a PATCH request to the Websets API
|
|
158
|
+
# @param path [String] API endpoint path
|
|
159
|
+
# @param body [Hash] Request body
|
|
160
|
+
# @return [Hash] Parsed response
|
|
161
|
+
def websets_patch(path, body)
|
|
162
|
+
response = @websets_connection.patch(path) do |req|
|
|
163
|
+
req.headers["x-api-key"] = config.api_key
|
|
164
|
+
req.headers["Content-Type"] = "application/json"
|
|
165
|
+
req.body = body
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
handle_response(response)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Handles API response and raises appropriate errors
|
|
172
|
+
# @param response [Faraday::Response] HTTP response
|
|
173
|
+
# @return [Hash] Parsed response body
|
|
174
|
+
# @raise [Exa::Error] on non-success status
|
|
175
|
+
def handle_response(response)
|
|
176
|
+
body = response.body
|
|
177
|
+
|
|
178
|
+
case response.status
|
|
179
|
+
when 200..299
|
|
180
|
+
body.is_a?(Hash) ? symbolize_keys(body) : body
|
|
181
|
+
else
|
|
182
|
+
raise Exa.error_for_status(
|
|
183
|
+
response.status,
|
|
184
|
+
response_body: body.is_a?(Hash) ? symbolize_keys(body) : nil,
|
|
185
|
+
headers: response.headers
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
rescue Faraday::TimeoutError => e
|
|
189
|
+
raise TimeoutError, "Request timed out: #{e.message}"
|
|
190
|
+
rescue Faraday::ConnectionFailed => e
|
|
191
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Recursively symbolizes hash keys
|
|
195
|
+
# @param hash [Hash, Array, Object] Object to process
|
|
196
|
+
# @return [Hash, Array, Object] Object with symbolized keys
|
|
197
|
+
def symbolize_keys(hash)
|
|
198
|
+
case hash
|
|
199
|
+
when Hash
|
|
200
|
+
hash.each_with_object({}) do |(key, value), result|
|
|
201
|
+
result[key.to_sym] = symbolize_keys(value)
|
|
202
|
+
end
|
|
203
|
+
when Array
|
|
204
|
+
hash.map { |item| symbolize_keys(item) }
|
|
205
|
+
else
|
|
206
|
+
hash
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|