use clap::{Parser, Subcommand, ValueEnum};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::env;
use std::process;
#[derive(Parser)]
#[command(name = "exauro")]
#[command(about = "A command-line wrapper for the Exa search API", long_about = None)]
#[command(version)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
Search {
query: String,
#[arg(long, value_enum, default_value_t = SearchType::Neural)]
search_type: SearchType,
#[arg(short = 'n', long, default_value_t = 5)]
num_results: usize,
#[arg(long)]
json: bool,
},
Similar {
url: String,
#[arg(short = 'n', long, default_value_t = 5)]
num_results: usize,
#[arg(long)]
json: bool,
},
Contents {
url: String,
#[arg(long)]
json: bool,
},
Answer {
query: String,
#[arg(long)]
json: bool,
},
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize)]
#[serde(rename_all = "lowercase")]
enum SearchType {
Auto,
Neural,
Fast,
Deep,
}
#[derive(Deserialize, Serialize, Debug)]
struct SearchResult {
title: Option<String>,
url: String,
summary: Option<String>,
text: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
struct SearchResponse {
results: Vec<SearchResult>,
}
#[derive(Deserialize, Serialize, Debug)]
struct Citation {
url: String,
title: Option<String>,
}
#[derive(Deserialize, Serialize, Debug)]
struct AnswerResponse {
answer: String,
citations: Vec<Citation>,
}
fn main() {
let cli = Cli::parse();
let api_key = match env::var("EXA_API_KEY") {
Ok(key) => key,
Err(_) => {
eprintln!("Set EXA_API_KEY env var (stored in 1Password vault Agents, item Agent Environment)");
process::exit(1);
}
};
let client = Client::new();
let base_url = "https://api.exa.ai";
match cli.command {
Commands::Search {
query,
search_type,
num_results,
json,
} => {
let body = json!({
"query": query,
"type": search_type,
"numResults": num_results,
"contents": { "summary": true }
});
handle_post_request(&client, &format!("{}/search", base_url), &api_key, body, json, |resp: SearchResponse| {
for (i, result) in resp.results.iter().enumerate() {
let title = result.title.as_deref().unwrap_or("No Title");
let summary = result.summary.as_deref().unwrap_or("No Summary");
let summary_truncated = if summary.len() > 200 {
format!("{}...", &summary[..197])
} else {
summary.to_string()
};
println!("{}. {}\n {}\n {}\n", i + 1, title, result.url, summary_truncated);
}
});
}
Commands::Similar {
url,
num_results,
json,
} => {
let body = json!({
"url": url,
"numResults": num_results,
"contents": { "summary": true }
});
handle_post_request(&client, &format!("{}/findSimilar", base_url), &api_key, body, json, |resp: SearchResponse| {
for (i, result) in resp.results.iter().enumerate() {
let title = result.title.as_deref().unwrap_or("No Title");
let summary = result.summary.as_deref().unwrap_or("No Summary");
let summary_truncated = if summary.len() > 200 {
format!("{}...", &summary[..197])
} else {
summary.to_string()
};
println!("{}. {}\n {}\n {}\n", i + 1, title, result.url, summary_truncated);
}
});
}
Commands::Contents { url, json } => {
let body = json!({
"ids": [url],
"livecrawl": "always"
});
handle_post_request(&client, &format!("{}/contents", base_url), &api_key, body, json, |resp: SearchResponse| {
if let Some(result) = resp.results.first() {
let title = result.title.as_deref().unwrap_or("No Title");
let text = result.text.as_deref().unwrap_or("No Content");
println!("Title: {}\n\n{}", title, text);
} else {
eprintln!("No content found.");
}
});
}
Commands::Answer { query, json } => {
let body = json!({
"query": query,
"type": "auto"
});
handle_post_request(&client, &format!("{}/answer", base_url), &api_key, body, json, |resp: AnswerResponse| {
println!("{}\n", resp.answer);
if !resp.citations.is_empty() {
println!("Sources:");
for (i, c) in resp.citations.iter().enumerate() {
if let Some(ref t) = c.title {
println!("{}. {} — {}", i + 1, t, c.url);
} else {
println!("{}. {}", i + 1, c.url);
}
}
}
});
}
}
}
fn handle_post_request<T, F>(
client: &Client,
url: &str,
api_key: &str,
body: serde_json::Value,
as_json: bool,
on_success: F,
) where
T: Serialize + for<'de> Deserialize<'de>,
F: FnOnce(T),
{
let response = client
.post(url)
.header("x-api-key", api_key)
.json(&body)
.send();
match response {
Ok(res) => {
if !res.status().is_success() {
let status = res.status();
let error_text = res.text().unwrap_or_else(|_| "Could not read error response".to_string());
eprintln!("API Error ({}): {}", status, error_text);
process::exit(1);
}
if as_json {
match res.json::<serde_json::Value>() {
Ok(val) => println!("{}", serde_json::to_string_pretty(&val).unwrap()),
Err(e) => {
eprintln!("Failed to parse JSON response: {}", e);
process::exit(1);
}
}
} else {
match res.json::<T>() {
Ok(val) => on_success(val),
Err(e) => {
eprintln!("Failed to parse API response: {}", e);
process::exit(1);
}
}
}
}
Err(e) => {
eprintln!("Network Error: {}", e);
process::exit(1);
}
}
}