Skip to content

Commit 32265a0

Browse files
feat(cli): add new search parameters and update livecrawl behavior
- Remove deprecated "pdf" category from valid categories - Change --livecrawl to boolean flag (was mode-based), defaults to maxAgeHours=0 and livecrawlTimeout=5000 - Add --text-verbosity parameter (compact, standard, full) for controlling text extraction detail - Add --system-prompt parameter for AI-powered search customization - Add --moderation parameter for content moderation - Update ParameterConverter to handle system_prompt → systemPrompt conversion - Update tests to reflect new CLI parameter behavior and remove livecrawl mode validation These changes align the CLI with the latest Exa API specification for search parameters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7c2fdb2 commit 32265a0

5 files changed

Lines changed: 155 additions & 34 deletions

File tree

exe/exa-ai-search

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,8 @@ def print_help
1818
--type TYPE Search type: auto, neural, fast, deep, deep-reasoning,
1919
or instant (default: auto)
2020
--category CAT Focus on specific data category
21-
Options: "company", "research paper", "news", "pdf",
22-
"github", "tweet", "personal site", "financial report",
23-
"people"
21+
Options: "company", "research paper", "news", "github",
22+
"tweet", "personal site", "financial report", "people"
2423
--include-domains D Comma-separated list of domains to include
2524
--exclude-domains D Comma-separated list of domains to exclude
2625
--start-published-date DATE Filter by published date (ISO 8601 format)
@@ -33,6 +32,7 @@ def print_help
3332
Content Extraction:
3433
--text Include full webpage text
3534
--text-max-characters N Max characters for webpage text
35+
--text-verbosity LEVEL Text verbosity: compact, standard, or full
3636
--include-html-tags Include HTML tags in text extraction
3737
--summary Include AI-generated summary
3838
--summary-query PROMPT Custom prompt for summary generation
@@ -48,14 +48,16 @@ def print_help
4848
--highlights-num-sentences N Number of sentences per highlight
4949
--highlights-per-url N Number of highlights per URL
5050
--highlights-query QUERY Custom query for highlight extraction
51-
--livecrawl MODE Livecrawl mode: always, fallback, never, auto, or preferred
52-
--livecrawl-timeout N Livecrawl timeout in milliseconds
51+
--livecrawl Enable livecrawl (sets maxAgeHours=0, livecrawlTimeout=5000)
52+
--livecrawl-timeout N Livecrawl timeout in milliseconds (overrides --livecrawl default)
5353
--max-age-hours N Maximum age of results in hours
5454
5555
Search Options:
5656
--additional-queries QUERY Additional search queries (repeatable)
5757
--output-schema JSON JSON schema for output structure (@file syntax)
5858
--user-location CODE User location (ISO country code)
59+
--system-prompt PROMPT System prompt for AI-powered search
60+
--moderation Enable content moderation
5961
6062
General Options:
6163
--api-key KEY Exa API key (or set EXA_API_KEY env var)
@@ -79,10 +81,11 @@ def build_contents(args)
7981

8082
# Text options
8183
if args[:text]
82-
if args[:text_max_characters] || args[:include_html_tags]
84+
if args[:text_max_characters] || args[:include_html_tags] || args[:text_verbosity]
8385
contents[:text] = {}
8486
contents[:text][:max_characters] = args[:text_max_characters] if args[:text_max_characters]
8587
contents[:text][:include_html_tags] = args[:include_html_tags] if args[:include_html_tags]
88+
contents[:text][:verbosity] = args[:text_verbosity] if args[:text_verbosity]
8689
else
8790
contents[:text] = true
8891
end
@@ -134,9 +137,13 @@ def build_contents(args)
134137
end
135138

136139
# Livecrawl options
137-
contents[:livecrawl] = args[:livecrawl] if args[:livecrawl]
138-
contents[:livecrawl_timeout] = args[:livecrawl_timeout] if args[:livecrawl_timeout]
139-
contents[:max_age_hours] = args[:max_age_hours] if args[:max_age_hours]
140+
if args[:livecrawl]
141+
contents[:max_age_hours] = args[:max_age_hours].nil? ? 0 : args[:max_age_hours]
142+
contents[:livecrawl_timeout] = args[:livecrawl_timeout].nil? ? 5000 : args[:livecrawl_timeout]
143+
else
144+
contents[:livecrawl_timeout] = args[:livecrawl_timeout] if args[:livecrawl_timeout]
145+
contents[:max_age_hours] = args[:max_age_hours] if args[:max_age_hours]
146+
end
140147

141148
contents.empty? ? nil : contents
142149
end
@@ -177,6 +184,8 @@ begin
177184
search_params[:additional_queries] = args[:additional_queries] if args[:additional_queries]
178185
search_params[:output_schema] = args[:output_schema] if args[:output_schema]
179186
search_params[:user_location] = args[:user_location] if args[:user_location]
187+
search_params[:system_prompt] = args[:system_prompt] if args[:system_prompt]
188+
search_params[:moderation] = args[:moderation] if args[:moderation]
180189
contents = build_contents(args)
181190
search_params.merge!(contents) if contents
182191

lib/exa/cli/search_parser.rb

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ module Exa
44
module CLI
55
class SearchParser
66
VALID_SEARCH_TYPES = ["auto", "neural", "fast", "deep", "deep-reasoning", "instant"].freeze
7-
VALID_LIVECRAWL_MODES = ["always", "fallback", "never", "auto", "preferred"].freeze
7+
VALID_TEXT_VERBOSITY = ["compact", "standard", "full"].freeze
88
VALID_CATEGORIES = [
9-
"company", "research paper", "news", "pdf", "github",
9+
"company", "research paper", "news", "github",
1010
"tweet", "personal site", "financial report", "people"
1111
].freeze
1212

@@ -140,10 +140,8 @@ def parse_arguments
140140
@args[:highlights_query] = @argv[i + 1]
141141
i += 2
142142
when "--livecrawl"
143-
livecrawl = @argv[i + 1]
144-
validate_livecrawl(livecrawl)
145-
@args[:livecrawl] = livecrawl
146-
i += 2
143+
@args[:livecrawl] = true
144+
i += 1
147145
when "--livecrawl-timeout"
148146
@args[:livecrawl_timeout] = @argv[i + 1].to_i
149147
i += 2
@@ -165,6 +163,17 @@ def parse_arguments
165163
when "--user-location"
166164
@args[:user_location] = @argv[i + 1]
167165
i += 2
166+
when "--system-prompt"
167+
@args[:system_prompt] = @argv[i + 1]
168+
i += 2
169+
when "--moderation"
170+
@args[:moderation] = true
171+
i += 1
172+
when "--text-verbosity"
173+
verbosity = @argv[i + 1]
174+
validate_text_verbosity(verbosity)
175+
@args[:text_verbosity] = verbosity
176+
i += 2
168177
else
169178
query_parts << arg
170179
i += 1
@@ -184,17 +193,17 @@ def validate_search_type(search_type)
184193
raise ArgumentError, "Search type must be one of: #{VALID_SEARCH_TYPES.join(', ')}"
185194
end
186195

187-
def validate_livecrawl(mode)
188-
return if VALID_LIVECRAWL_MODES.include?(mode)
189-
190-
raise ArgumentError, "Livecrawl mode must be one of: #{VALID_LIVECRAWL_MODES.join(', ')}"
191-
end
192-
193196
def validate_category(category)
194197
return if VALID_CATEGORIES.include?(category)
195198

196199
raise ArgumentError, "Category must be one of: #{VALID_CATEGORIES.map { |c| "\"#{c}\"" }.join(', ')}"
197200
end
201+
202+
def validate_text_verbosity(verbosity)
203+
return if VALID_TEXT_VERBOSITY.include?(verbosity)
204+
205+
raise ArgumentError, "Text verbosity must be one of: #{VALID_TEXT_VERBOSITY.join(', ')}"
206+
end
198207
end
199208
end
200209
end

lib/exa/services/parameter_converter.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def convert_key(key)
4242
when :num_results then :numResults
4343
when :include_domains then :includeDomains
4444
when :exclude_domains then :excludeDomains
45+
when :system_prompt then :systemPrompt
4546
else
4647
key
4748
end
@@ -112,7 +113,8 @@ def text_hash_mappings
112113
max_characters: :maxCharacters,
113114
include_html_tags: :includeHtmlTags,
114115
include_sections: :includeSections,
115-
exclude_sections: :excludeSections
116+
exclude_sections: :excludeSections,
117+
verbosity: :verbosity
116118
}
117119
end
118120

test/cli/search_test.rb

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def test_parses_category_company_flag
6969
end
7070

7171
def test_parses_all_valid_categories
72-
valid_categories = ["company", "research paper", "news", "pdf", "github", "tweet", "personal site", "financial report", "people"]
72+
valid_categories = ["company", "research paper", "news", "github", "tweet", "personal site", "financial report", "people"]
7373
valid_categories.each do |category|
7474
args = parse_search_args(["test query", "--category", category])
7575
assert_equal category, args[:category], "Failed to parse category: #{category}"
@@ -83,13 +83,25 @@ def test_rejects_invalid_category
8383
assert_includes error.message.downcase, "category"
8484
end
8585

86+
def test_rejects_pdf_category
87+
error = assert_raises(ArgumentError) do
88+
parse_search_args(["test query", "--category", "pdf"])
89+
end
90+
assert_includes error.message.downcase, "category"
91+
end
92+
8693
def test_rejects_obsolete_linkedin_profile_category
8794
error = assert_raises(ArgumentError) do
8895
parse_search_args(["test query", "--category", "linkedin profile"])
8996
end
9097
assert_includes error.message.downcase, "category"
9198
end
9299

100+
def test_parses_category_github_flag
101+
args = parse_search_args(["test query", "--category", "github"])
102+
assert_equal "github", args[:category]
103+
end
104+
93105
def test_parses_highlights_flag
94106
args = parse_search_args(["test query", "--highlights"])
95107
assert_equal true, args[:highlights]
@@ -107,17 +119,8 @@ def test_parses_highlights_options
107119
end
108120

109121
def test_parses_livecrawl_flag
110-
%w[always fallback never auto preferred].each do |mode|
111-
args = parse_search_args(["test query", "--livecrawl", mode])
112-
assert_equal mode, args[:livecrawl], "Failed to parse livecrawl mode: #{mode}"
113-
end
114-
end
115-
116-
def test_rejects_invalid_livecrawl_mode
117-
error = assert_raises(ArgumentError) do
118-
parse_search_args(["test query", "--livecrawl", "bogus"])
119-
end
120-
assert_includes error.message.downcase, "livecrawl"
122+
args = parse_search_args(["test query", "--livecrawl"])
123+
assert_equal true, args[:livecrawl]
121124
end
122125

123126
def test_parses_livecrawl_timeout_flag
@@ -146,6 +149,28 @@ def test_parses_user_location_flag
146149
assert_equal "US", args[:user_location]
147150
end
148151

152+
def test_parses_system_prompt_flag
153+
args = parse_search_args(["test query", "--system-prompt", "You are a helpful assistant"])
154+
assert_equal "You are a helpful assistant", args[:system_prompt]
155+
end
156+
157+
def test_parses_moderation_flag
158+
args = parse_search_args(["test query", "--moderation"])
159+
assert_equal true, args[:moderation]
160+
end
161+
162+
def test_parses_text_verbosity_flag
163+
args = parse_search_args(["test query", "--text-verbosity", "compact"])
164+
assert_equal "compact", args[:text_verbosity]
165+
end
166+
167+
def test_rejects_invalid_text_verbosity
168+
error = assert_raises(ArgumentError) do
169+
parse_search_args(["test query", "--text-verbosity", "verbose"])
170+
end
171+
assert_includes error.message.downcase, "verbosity"
172+
end
173+
149174
def test_handles_api_error_gracefully
150175
stub_request(:post, "https://api.exa.ai/search")
151176
.to_return(status: 500, body: { error: "Internal Server Error" }.to_json)

test/services/search_test.rb

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,82 @@ def test_call_converts_text_include_sections
808808
assert_requested :post, "https://api.exa.ai/search"
809809
end
810810

811+
def test_call_handles_text_verbosity_in_contents
812+
stub_request(:post, "https://api.exa.ai/search")
813+
.with(
814+
body: hash_including(
815+
query: "test",
816+
contents: {
817+
text: {
818+
verbosity: "standard"
819+
}
820+
}
821+
)
822+
)
823+
.to_return(
824+
status: 200,
825+
body: { results: [], requestId: "test123" }.to_json,
826+
headers: { "Content-Type" => "application/json" }
827+
)
828+
829+
service = Exa::Services::Search.new(
830+
@connection,
831+
query: "test",
832+
text: { verbosity: "standard" }
833+
)
834+
service.call
835+
836+
assert_requested :post, "https://api.exa.ai/search"
837+
end
838+
839+
def test_call_passes_moderation_parameter
840+
stub_request(:post, "https://api.exa.ai/search")
841+
.with(
842+
body: hash_including(
843+
query: "test",
844+
moderation: true
845+
)
846+
)
847+
.to_return(
848+
status: 200,
849+
body: { results: [], requestId: "test123" }.to_json,
850+
headers: { "Content-Type" => "application/json" }
851+
)
852+
853+
service = Exa::Services::Search.new(
854+
@connection,
855+
query: "test",
856+
moderation: true
857+
)
858+
service.call
859+
860+
assert_requested :post, "https://api.exa.ai/search"
861+
end
862+
863+
def test_call_converts_system_prompt_parameter
864+
stub_request(:post, "https://api.exa.ai/search")
865+
.with(
866+
body: hash_including(
867+
query: "test",
868+
systemPrompt: "You are a research assistant"
869+
)
870+
)
871+
.to_return(
872+
status: 200,
873+
body: { results: [], requestId: "test123" }.to_json,
874+
headers: { "Content-Type" => "application/json" }
875+
)
876+
877+
service = Exa::Services::Search.new(
878+
@connection,
879+
query: "test",
880+
system_prompt: "You are a research assistant"
881+
)
882+
service.call
883+
884+
assert_requested :post, "https://api.exa.ai/search"
885+
end
886+
811887
def test_call_converts_num_results_parameter
812888
stub_request(:post, "https://api.exa.ai/search")
813889
.with(

0 commit comments

Comments
 (0)