exa-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +562 -0
  4. data/exe/exa-ai +95 -0
  5. data/exe/exa-ai-answer +131 -0
  6. data/exe/exa-ai-context +104 -0
  7. data/exe/exa-ai-get-contents +114 -0
  8. data/exe/exa-ai-research-get +110 -0
  9. data/exe/exa-ai-research-list +95 -0
  10. data/exe/exa-ai-research-start +175 -0
  11. data/exe/exa-ai-search +134 -0
  12. data/lib/exa/cli/base.rb +51 -0
  13. data/lib/exa/cli/error_handler.rb +98 -0
  14. data/lib/exa/cli/formatters/answer_formatter.rb +63 -0
  15. data/lib/exa/cli/formatters/contents_formatter.rb +50 -0
  16. data/lib/exa/cli/formatters/context_formatter.rb +58 -0
  17. data/lib/exa/cli/formatters/research_formatter.rb +120 -0
  18. data/lib/exa/cli/formatters/search_formatter.rb +44 -0
  19. data/lib/exa/cli/polling.rb +46 -0
  20. data/lib/exa/client.rb +132 -0
  21. data/lib/exa/connection.rb +32 -0
  22. data/lib/exa/error.rb +31 -0
  23. data/lib/exa/middleware/raise_error.rb +55 -0
  24. data/lib/exa/resources/answer.rb +20 -0
  25. data/lib/exa/resources/contents_result.rb +29 -0
  26. data/lib/exa/resources/context_result.rb +37 -0
  27. data/lib/exa/resources/find_similar_result.rb +28 -0
  28. data/lib/exa/resources/research_list.rb +18 -0
  29. data/lib/exa/resources/research_task.rb +39 -0
  30. data/lib/exa/resources/search_result.rb +30 -0
  31. data/lib/exa/services/answer.rb +23 -0
  32. data/lib/exa/services/context.rb +27 -0
  33. data/lib/exa/services/find_similar.rb +26 -0
  34. data/lib/exa/services/get_contents.rb +25 -0
  35. data/lib/exa/services/research_get.rb +30 -0
  36. data/lib/exa/services/research_list.rb +37 -0
  37. data/lib/exa/services/research_start.rb +26 -0
  38. data/lib/exa/services/search.rb +26 -0
  39. data/lib/exa/version.rb +5 -0
  40. data/lib/exa.rb +54 -0
  41. metadata +174 -0
@@ -0,0 +1,175 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Set up load paths
5
+ require "bundler/setup"
6
+ require "exa"
7
+
8
+ # Parse command-line arguments
9
+ def parse_args(argv)
10
+ args = {
11
+ output_format: "json",
12
+ api_key: nil,
13
+ events: false,
14
+ wait: false
15
+ }
16
+
17
+ i = 0
18
+ while i < argv.length
19
+ arg = argv[i]
20
+ case arg
21
+ when "--instructions"
22
+ args[:instructions] = argv[i + 1]
23
+ i += 2
24
+ when "--model"
25
+ args[:model] = argv[i + 1]
26
+ i += 2
27
+ when "--output-schema"
28
+ args[:output_schema] = argv[i + 1]
29
+ i += 2
30
+ when "--wait"
31
+ args[:wait] = true
32
+ i += 1
33
+ when "--events"
34
+ args[:events] = true
35
+ i += 1
36
+ when "--api-key"
37
+ args[:api_key] = argv[i + 1]
38
+ i += 2
39
+ when "--output-format"
40
+ args[:output_format] = argv[i + 1]
41
+ i += 2
42
+ when "--help", "-h"
43
+ puts <<~HELP
44
+ Usage: exa-api research-start --instructions "TEXT" [OPTIONS]
45
+
46
+ Start a research task using Exa AI
47
+
48
+ Options:
49
+ --instructions TEXT Research instructions (required)
50
+ --model MODEL Model to use (e.g., gpt-4, gpt-3.5-turbo)
51
+ --output-schema JSON JSON schema string for structured output
52
+ --wait Wait for task to complete (polls until done)
53
+ --events Include event log in output (only with --wait)
54
+ --api-key KEY Exa API key (or set EXA_API_KEY env var)
55
+ --output-format FMT Output format: json, pretty, or text (default: json)
56
+ --help, -h Show this help message
57
+
58
+ Examples:
59
+ exa-api research-start --instructions "Find Ruby performance tips"
60
+ exa-api research-start --instructions "Analyze AI trends" --wait --events
61
+ exa-api research-start --instructions "Summarize papers" --model gpt-4 --wait
62
+ exa-api research-start --instructions "Find stats" --output-schema '{"type":"object"}'
63
+ HELP
64
+ exit 0
65
+ else
66
+ i += 1
67
+ end
68
+ end
69
+
70
+ args
71
+ end
72
+
73
+ # Main execution
74
+ begin
75
+ args = parse_args(ARGV)
76
+
77
+ # Validate instructions
78
+ if args[:instructions].nil? || args[:instructions].empty?
79
+ $stderr.puts "Error: --instructions flag is required"
80
+ $stderr.puts "Run 'exa-api research-start --help' for usage information"
81
+ exit 1
82
+ end
83
+
84
+ # Resolve API key
85
+ api_key = Exa::CLI::Base.resolve_api_key(args[:api_key])
86
+
87
+ # Resolve output format
88
+ output_format = Exa::CLI::Base.resolve_output_format(args[:output_format])
89
+
90
+ # Build client
91
+ client = Exa::CLI::Base.build_client(api_key)
92
+
93
+ # Prepare research parameters
94
+ research_params = { instructions: args[:instructions] }
95
+ research_params[:model] = args[:model] if args[:model]
96
+
97
+ # Parse output_schema as JSON if provided
98
+ if args[:output_schema]
99
+ begin
100
+ research_params[:output_schema] = JSON.parse(args[:output_schema])
101
+ rescue JSON::ParserError => e
102
+ $stderr.puts "Error: Invalid JSON in --output-schema: #{e.message}"
103
+ exit 1
104
+ end
105
+ end
106
+
107
+ # Start research task
108
+ task = client.research_start(**research_params)
109
+
110
+ # If --wait flag is set, poll until task completes
111
+ if args[:wait]
112
+ $stderr.print "Starting research task... "
113
+
114
+ begin
115
+ final_task = Exa::CLI::Polling.poll(max_duration: 300, initial_delay: 2, max_delay: 10) do
116
+ # Get current task status
117
+ current_task = client.research_get(task.research_id, events: args[:events])
118
+
119
+ # Show progress indicator
120
+ case current_task.status
121
+ when "pending"
122
+ $stderr.print "⏳"
123
+ when "running"
124
+ $stderr.print "⚙️"
125
+ end
126
+
127
+ # Check if done
128
+ done = current_task.finished?
129
+
130
+ { done: done, result: current_task, status: current_task.status }
131
+ end
132
+
133
+ $stderr.puts " #{final_task.status.upcase}"
134
+
135
+ # Format and output final result
136
+ output = Exa::CLI::Formatters::ResearchFormatter.format_task(final_task, output_format, show_events: args[:events])
137
+ puts output
138
+
139
+ # Exit with error code if task failed
140
+ exit 1 if final_task.failed?
141
+
142
+ rescue Exa::CLI::Polling::TimeoutError => e
143
+ $stderr.puts "\nError: Task did not complete within timeout period (5 minutes)"
144
+ $stderr.puts "Task ID: #{task.research_id}"
145
+ $stderr.puts "You can check the status later with: exa-api research-get #{task.research_id}"
146
+ exit 1
147
+ end
148
+ else
149
+ # Just return the initial task (with status "pending")
150
+ output = Exa::CLI::Formatters::ResearchFormatter.format_task(task, output_format, show_events: false)
151
+ puts output
152
+ end
153
+
154
+ rescue Exa::ConfigurationError => e
155
+ $stderr.puts "Configuration error: #{e.message}"
156
+ exit 1
157
+ rescue Exa::Unauthorized => e
158
+ $stderr.puts "Authentication error: #{e.message}"
159
+ $stderr.puts "Check your API key (set EXA_API_KEY or use --api-key)"
160
+ exit 1
161
+ rescue Exa::ClientError => e
162
+ $stderr.puts "Client error: #{e.message}"
163
+ exit 1
164
+ rescue Exa::ServerError => e
165
+ $stderr.puts "Server error: #{e.message}"
166
+ $stderr.puts "The Exa API may be experiencing issues. Please try again later."
167
+ exit 1
168
+ rescue Exa::Error => e
169
+ $stderr.puts "Error: #{e.message}"
170
+ exit 1
171
+ rescue StandardError => e
172
+ $stderr.puts "Unexpected error: #{e.message}"
173
+ $stderr.puts e.backtrace.first(5) if ENV["DEBUG"]
174
+ exit 1
175
+ end
data/exe/exa-ai-search ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Set up load paths
5
+ require "bundler/setup"
6
+ require "exa"
7
+
8
+ # Parse command-line arguments
9
+ def parse_args(argv)
10
+ args = {
11
+ output_format: "json",
12
+ api_key: nil
13
+ }
14
+
15
+ # Extract query (first non-flag argument)
16
+ query_parts = []
17
+ i = 0
18
+ while i < argv.length
19
+ arg = argv[i]
20
+ case arg
21
+ when "--num-results"
22
+ args[:num_results] = argv[i + 1].to_i
23
+ i += 2
24
+ when "--type"
25
+ args[:type] = argv[i + 1]
26
+ i += 2
27
+ when "--include-domains"
28
+ args[:include_domains] = argv[i + 1].split(",").map(&:strip)
29
+ i += 2
30
+ when "--exclude-domains"
31
+ args[:exclude_domains] = argv[i + 1].split(",").map(&:strip)
32
+ i += 2
33
+ when "--use-autoprompt"
34
+ args[:use_autoprompt] = true
35
+ i += 1
36
+ when "--api-key"
37
+ args[:api_key] = argv[i + 1]
38
+ i += 2
39
+ when "--output-format"
40
+ args[:output_format] = argv[i + 1]
41
+ i += 2
42
+ when "--help", "-h"
43
+ puts <<~HELP
44
+ Usage: exa-api search QUERY [OPTIONS]
45
+
46
+ Search the web using Exa AI
47
+
48
+ Arguments:
49
+ QUERY Search query (required)
50
+
51
+ Options:
52
+ --num-results N Number of results to return (default: 10)
53
+ --type TYPE Search type: keyword, neural, or auto (default: auto)
54
+ --include-domains D Comma-separated list of domains to include
55
+ --exclude-domains D Comma-separated list of domains to exclude
56
+ --use-autoprompt Use Exa's autoprompt feature
57
+ --api-key KEY Exa API key (or set EXA_API_KEY env var)
58
+ --output-format FMT Output format: json, pretty, or text (default: json)
59
+ --help, -h Show this help message
60
+
61
+ Examples:
62
+ exa-api search "ruby programming"
63
+ exa-api search "machine learning" --num-results 5 --type keyword
64
+ exa-api search "AI research" --include-domains arxiv.org,scholar.google.com
65
+ exa-api search "tutorials" --output-format pretty
66
+ HELP
67
+ exit 0
68
+ else
69
+ query_parts << arg
70
+ i += 1
71
+ end
72
+ end
73
+
74
+ args[:query] = query_parts.join(" ")
75
+ args
76
+ end
77
+
78
+ # Main execution
79
+ begin
80
+ args = parse_args(ARGV)
81
+
82
+ # Validate query
83
+ if args[:query].nil? || args[:query].empty?
84
+ $stderr.puts "Error: Query is required"
85
+ $stderr.puts "Run 'exa-api search --help' for usage information"
86
+ exit 1
87
+ end
88
+
89
+ # Resolve API key
90
+ api_key = Exa::CLI::Base.resolve_api_key(args[:api_key])
91
+
92
+ # Resolve output format
93
+ output_format = Exa::CLI::Base.resolve_output_format(args[:output_format])
94
+
95
+ # Build client
96
+ client = Exa::CLI::Base.build_client(api_key)
97
+
98
+ # Prepare search parameters
99
+ search_params = {}
100
+ search_params[:num_results] = args[:num_results] if args[:num_results]
101
+ search_params[:type] = args[:type] if args[:type]
102
+ search_params[:include_domains] = args[:include_domains] if args[:include_domains]
103
+ search_params[:exclude_domains] = args[:exclude_domains] if args[:exclude_domains]
104
+ search_params[:use_autoprompt] = args[:use_autoprompt] if args[:use_autoprompt]
105
+
106
+ # Execute search
107
+ result = client.search(args[:query], **search_params)
108
+
109
+ # Format and output result
110
+ output = Exa::CLI::Formatters::SearchFormatter.format(result, output_format)
111
+ puts output
112
+
113
+ rescue Exa::ConfigurationError => e
114
+ $stderr.puts "Configuration error: #{e.message}"
115
+ exit 1
116
+ rescue Exa::Unauthorized => e
117
+ $stderr.puts "Authentication error: #{e.message}"
118
+ $stderr.puts "Check your API key (set EXA_API_KEY or use --api-key)"
119
+ exit 1
120
+ rescue Exa::ClientError => e
121
+ $stderr.puts "Client error: #{e.message}"
122
+ exit 1
123
+ rescue Exa::ServerError => e
124
+ $stderr.puts "Server error: #{e.message}"
125
+ $stderr.puts "The Exa API may be experiencing issues. Please try again later."
126
+ exit 1
127
+ rescue Exa::Error => e
128
+ $stderr.puts "Error: #{e.message}"
129
+ exit 1
130
+ rescue StandardError => e
131
+ $stderr.puts "Unexpected error: #{e.message}"
132
+ $stderr.puts e.backtrace.first(5) if ENV["DEBUG"]
133
+ exit 1
134
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module CLI
5
+ class Base
6
+ # Resolve API key from flag or environment variable
7
+ # Flag takes precedence over environment variable
8
+ def self.resolve_api_key(flag_value)
9
+ return flag_value if flag_value && !flag_value.empty?
10
+
11
+ env_key = ENV["EXA_API_KEY"]
12
+ return env_key if env_key && !env_key.empty?
13
+
14
+ raise ConfigurationError,
15
+ "Missing API key. Set EXA_API_KEY environment variable or use --api-key flag"
16
+ end
17
+
18
+ # Resolve and validate output format
19
+ # Valid formats: json, pretty, text
20
+ # Defaults to json
21
+ def self.resolve_output_format(flag_value)
22
+ format = (flag_value || "json").downcase
23
+ valid_formats = %w[json pretty text]
24
+
25
+ return format if valid_formats.include?(format)
26
+
27
+ raise ConfigurationError,
28
+ "Invalid output format: #{format}. Valid formats: #{valid_formats.join(', ')}"
29
+ end
30
+
31
+ # Build a client instance with the given API key
32
+ def self.build_client(api_key, **options)
33
+ Client.new(api_key: api_key, **options)
34
+ end
35
+
36
+ # Format output data based on format type
37
+ def self.format_output(data, format)
38
+ case format
39
+ when "json"
40
+ JSON.pretty_generate(data.is_a?(Hash) ? data : { result: data })
41
+ when "pretty"
42
+ data.inspect
43
+ when "text"
44
+ data.to_s
45
+ else
46
+ data.to_s
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module CLI
5
+ class ErrorHandler
6
+ def self.handle_error(error, command_name = nil)
7
+ case error
8
+ when ConfigurationError
9
+ handle_configuration_error(error, command_name)
10
+ when Unauthorized
11
+ handle_unauthorized_error(error, command_name)
12
+ when ClientError
13
+ handle_client_error(error, command_name)
14
+ when ServerError
15
+ handle_server_error(error, command_name)
16
+ else
17
+ handle_generic_error(error, command_name)
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def self.handle_configuration_error(error, command_name)
24
+ $stderr.puts "❌ Configuration Error"
25
+ $stderr.puts ""
26
+ $stderr.puts error.message
27
+ $stderr.puts ""
28
+ $stderr.puts "Solutions:"
29
+ $stderr.puts " 1. Set the EXA_API_KEY environment variable:"
30
+ $stderr.puts " export EXA_API_KEY='your-api-key'"
31
+ $stderr.puts ""
32
+ $stderr.puts " 2. Or pass it as a flag:"
33
+ $stderr.puts " #{command_name} ... --api-key YOUR_API_KEY" if command_name
34
+ $stderr.puts ""
35
+ $stderr.puts "Get your API key at: https://dashboard.exa.ai"
36
+ end
37
+
38
+ def self.handle_unauthorized_error(error, command_name)
39
+ $stderr.puts "❌ Authentication Error"
40
+ $stderr.puts ""
41
+ $stderr.puts "Your API key is invalid or has expired."
42
+ $stderr.puts ""
43
+ if error.response&.fetch("error", nil)
44
+ $stderr.puts "Details: #{error.response['error']}"
45
+ $stderr.puts ""
46
+ end
47
+ $stderr.puts "Solutions:"
48
+ $stderr.puts " 1. Verify your API key is correct"
49
+ $stderr.puts " 2. Check if your API key has expired or been revoked"
50
+ $stderr.puts " 3. Get a new key from: https://dashboard.exa.ai"
51
+ end
52
+
53
+ def self.handle_client_error(error, command_name)
54
+ $stderr.puts "❌ Request Error"
55
+ $stderr.puts ""
56
+ $stderr.puts error.message
57
+ $stderr.puts ""
58
+
59
+ # Try to extract status code from response
60
+ status = error.response&.fetch("status", "unknown") if error.response.is_a?(Hash)
61
+
62
+ case status
63
+ when 400
64
+ $stderr.puts "This was a bad request. Please check your arguments."
65
+ when 404
66
+ $stderr.puts "The requested resource was not found."
67
+ when 422
68
+ $stderr.puts "The request data was invalid. Check your parameters."
69
+ when 429
70
+ $stderr.puts "You've exceeded the rate limit. Please wait before trying again."
71
+ end
72
+
73
+ $stderr.puts ""
74
+ $stderr.puts "Run '#{command_name} --help' for usage information." if command_name
75
+ end
76
+
77
+ def self.handle_server_error(error, command_name)
78
+ $stderr.puts "❌ Server Error"
79
+ $stderr.puts ""
80
+ $stderr.puts "The Exa API encountered an error:"
81
+ $stderr.puts error.message
82
+ $stderr.puts ""
83
+ $stderr.puts "Solutions:"
84
+ $stderr.puts " 1. Try again in a moment"
85
+ $stderr.puts " 2. Check API status: https://status.exa.ai"
86
+ $stderr.puts " 3. Contact support if the error persists"
87
+ end
88
+
89
+ def self.handle_generic_error(error, command_name)
90
+ $stderr.puts "❌ Error"
91
+ $stderr.puts ""
92
+ $stderr.puts error.message
93
+ $stderr.puts ""
94
+ $stderr.puts "Run '#{command_name} --help' for usage information." if command_name
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module CLI
5
+ module Formatters
6
+ class AnswerFormatter
7
+ def self.format(result, format)
8
+ case format
9
+ when "json"
10
+ JSON.pretty_generate(result.to_h)
11
+ when "pretty"
12
+ format_pretty(result)
13
+ when "text"
14
+ format_text(result)
15
+ else
16
+ JSON.pretty_generate(result.to_h)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def self.format_pretty(result)
23
+ output = []
24
+ output << "Answer:"
25
+ output << "-" * 60
26
+
27
+ # Handle both string and structured (hash) answers
28
+ if result.answer.is_a?(Hash)
29
+ output << JSON.pretty_generate(result.answer)
30
+ else
31
+ output << result.answer
32
+ end
33
+ output << ""
34
+
35
+ if result.citations && !result.citations.empty?
36
+ output << "Citations:"
37
+ output << "-" * 60
38
+ result.citations.each_with_index do |citation, idx|
39
+ output << "[#{idx + 1}] #{citation['title']}"
40
+ output << " URL: #{citation['url']}"
41
+ output << " Author: #{citation['author']}" if citation['author']
42
+ output << " Date: #{citation['publishedDate']}" if citation['publishedDate']
43
+ output << ""
44
+ end
45
+ end
46
+
47
+ output << "Cost: $#{result.cost_dollars}" if result.cost_dollars
48
+
49
+ output.join("\n")
50
+ end
51
+
52
+ def self.format_text(result)
53
+ # Handle both string and structured (hash) answers
54
+ if result.answer.is_a?(Hash)
55
+ JSON.pretty_generate(result.answer)
56
+ else
57
+ result.answer
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Exa
4
+ module CLI
5
+ module Formatters
6
+ class ContentsFormatter
7
+ def self.format(result, format)
8
+ case format
9
+ when "json"
10
+ JSON.pretty_generate(result.to_h)
11
+ when "pretty"
12
+ format_pretty(result)
13
+ when "text"
14
+ format_text(result)
15
+ else
16
+ JSON.pretty_generate(result.to_h)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def self.format_pretty(result)
23
+ output = []
24
+ result.results.each_with_index do |content, idx|
25
+ output << "=== Content #{idx + 1} ==="
26
+ output << "URL: #{content['url']}"
27
+ output << "Title: #{content['title']}"
28
+ output << ""
29
+ output << "Text:"
30
+ output << "-" * 40
31
+ text = content['text'] || content['content'] || "(No text available)"
32
+ # Truncate long text to first 500 chars
33
+ truncated = text.length > 500 ? text[0..500] + "..." : text
34
+ output << truncated
35
+ output << ""
36
+ end
37
+ output.join("\n")
38
+ end
39
+
40
+ def self.format_text(result)
41
+ output = []
42
+ result.results.each do |content|
43
+ output << "#{content['url']}\n#{content['text'] || '(No text available)'}"
44
+ end
45
+ output.join("\n\n")
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Exa
6
+ module CLI
7
+ module Formatters
8
+ class ContextFormatter
9
+ def self.format(result, format)
10
+ case format
11
+ when "json"
12
+ JSON.pretty_generate(result.to_h)
13
+ when "pretty"
14
+ format_pretty(result)
15
+ when "text"
16
+ format_text(result)
17
+ else
18
+ JSON.pretty_generate(result.to_h)
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def self.format_pretty(result)
25
+ output = []
26
+ output << "=" * 60
27
+ output << "Query: #{result.query}"
28
+ output << "=" * 60
29
+ output << ""
30
+ output << "Metadata:"
31
+ output << " Request ID: #{result.request_id}"
32
+ output << " Results: #{result.results_count}"
33
+ output << " Cost: $#{result.cost_dollars}"
34
+ output << " Search Time: #{result.search_time}ms"
35
+ output << ""
36
+ output << "Code Context:"
37
+ output << "-" * 60
38
+ output << result.response.to_s
39
+ output.join("\n")
40
+ end
41
+
42
+ def self.format_text(result)
43
+ output = []
44
+ output << "Query: #{result.query}"
45
+ output << "Request ID: #{result.request_id}"
46
+ output << "Results: #{result.results_count}"
47
+ output << "Cost: $#{result.cost_dollars}"
48
+ output << "Search Time: #{result.search_time}ms"
49
+ output << ""
50
+ output << "Code Context:"
51
+ output << "-" * 40
52
+ output << result.response.to_s
53
+ output.join("\n")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end