mod cache;
mod cli;
mod config;
mod context;
mod engine;
mod errors;
mod logging;
mod output;
mod providers;
mod registry;
mod types;
mod usage;
mod verify;
use clap::Parser;
use cli::{Cli, Commands, ConfigAction, SkillAction};
use config::{config_check, config_set, config_show, load_config};
use context::AppContext;
use output::{Ctx, OutputFormat};
use std::sync::Arc;
use tokio::net::lookup_host;
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
#[derive(Default, Clone, Copy)]
struct GlobalFlags {
json: bool,
quiet: bool,
last: bool,
x_only: bool,
debug: bool,
}
fn scan_global_flags() -> GlobalFlags {
let mut f = GlobalFlags::default();
for arg in std::env::args_os().skip(1) {
if arg == "--" {
break;
}
match arg.to_str() {
Some("--json") => f.json = true,
Some("--quiet") => f.quiet = true,
Some("--last") => f.last = true,
Some("--x") => f.x_only = true,
Some("--debug") | Some("--verbose") => f.debug = true,
_ => {}
}
}
f
}
const GLOBAL_FLAG_TOKENS: &[&str] = &["--json", "--quiet", "--last", "--x", "--debug", "--verbose"];
fn init_tracing(debug: bool) {
use tracing_subscriber::{fmt, EnvFilter};
let default = if debug {
"debug,hyper=warn,hyper_util=warn,reqwest=warn,h2=warn,rustls=warn,tower=warn"
} else {
"warn"
};
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default));
let _ = fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.without_time()
.try_init();
}
#[tokio::main]
async fn main() {
tokio::spawn(async {
let domains = [
"api.parallel.ai:443",
"api.search.brave.com:443",
"google.serper.dev:443",
"api.exa.ai:443",
"api.jina.ai:443",
"api.tavily.com:443",
"api.perplexity.ai:443",
];
for domain in domains {
let _ = lookup_host(domain).await;
}
});
let config_handle = tokio::task::spawn_blocking(load_config);
let flags = scan_global_flags();
init_tracing(flags.debug);
let json_flag = flags.json;
let mut cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
if matches!(
e.kind(),
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
) {
let format = OutputFormat::detect(json_flag);
match format {
OutputFormat::Json => {
let envelope = serde_json::json!({
"version": "1",
"status": "success",
"data": { "usage": e.to_string().trim_end() },
});
println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
std::process::exit(0);
}
OutputFormat::Table => e.exit(),
}
}
let err = errors::SearchError::InvalidInput {
message: e.to_string(),
};
match OutputFormat::detect(json_flag) {
OutputFormat::Json => output::json::render_error(&err),
OutputFormat::Table => eprint!("{e}"),
}
std::process::exit(err.exit_code());
}
};
cli.json |= flags.json;
cli.quiet |= flags.quiet;
cli.last |= flags.last;
cli.x_only |= flags.x_only;
cli.debug |= flags.debug;
let ctx = Ctx::new(cli.json, cli.quiet);
let config = match config_handle.await {
Ok(Ok(c)) => c,
Ok(Err(e)) => {
let err = errors::SearchError::Config(e.to_string());
match OutputFormat::detect(json_flag) {
OutputFormat::Json => output::json::render_error(&err),
OutputFormat::Table => eprintln!("Error: {err}"),
}
std::process::exit(err.exit_code());
}
Err(join_err) => {
let err = errors::SearchError::Config(format!("config load task failed: {join_err}"));
match OutputFormat::detect(json_flag) {
OutputFormat::Json => output::json::render_error(&err),
OutputFormat::Table => eprintln!("Error: {err}"),
}
std::process::exit(err.exit_code());
}
};
let app = Arc::new(AppContext::new(config));
let is_search = cli.command.is_none() || matches!(cli.command, Some(Commands::Search(_)));
if is_search && !cli.last {
let app_c = app.clone();
tokio::spawn(async move {
let urls = [
"https://api.search.brave.com/res/v1/web/search",
"https://google.serper.dev/search",
"https://api.exa.ai/search",
];
for url in urls {
let _ = app_c.client.head(url).send().await;
}
});
}
let exit_code = match run(cli, &ctx, app).await {
Ok(code) => code,
Err(e) => {
if ctx.is_json() {
output::json::render_error(&e);
} else {
eprintln!("Error: {e}");
if let Some(s) = e.suggestion() {
eprintln!(" {s}");
}
if let errors::SearchError::AllProvidersFailed { failed } = &e {
for f in failed {
eprintln!(" - {} [{}]: {}", f.provider, f.category.as_str(), f.reason);
}
}
}
e.exit_code()
}
};
std::process::exit(exit_code);
}
async fn run(cli: Cli, ctx: &Ctx, app: Arc<AppContext>) -> Result<i32, errors::SearchError> {
let command = if let Some(cmd) = cli.command {
cmd
} else if cli.last {
Commands::Search(cli::SearchArgs {
query: String::new(),
mode: types::Mode::General,
count: None,
providers: None,
domain: None,
exclude_domain: None,
freshness: None,
})
} else if !cli.query_words.is_empty() {
let query = cli
.query_words
.iter()
.filter(|w| !GLOBAL_FLAG_TOKENS.contains(&w.as_str()))
.cloned()
.collect::<Vec<_>>()
.join(" ");
Commands::Search(cli::SearchArgs {
query,
mode: types::Mode::General,
count: None,
providers: None,
domain: None,
exclude_domain: None,
freshness: None,
})
} else {
use clap::CommandFactory;
if ctx.is_json() {
let mut buf = Vec::new();
Cli::command().write_long_help(&mut buf).ok();
let envelope = serde_json::json!({
"version": "1",
"status": "success",
"data": { "usage": String::from_utf8_lossy(&buf).trim_end() },
});
println!("{}", serde_json::to_string_pretty(&envelope).unwrap());
} else {
Cli::command().print_help().ok();
println!();
}
return Ok(0);
};
match command {
Commands::Search(mut args) => {
if cli.x_only {
args.mode = types::Mode::Social;
args.providers = Some(vec!["xai".to_string()]);
}
if cli.last {
if let Some(mut cached) = cache::load_last() {
cached.metadata.cached = true;
cached.metadata.cache_age_secs = cache::last_age_secs();
if ctx.is_json() {
output::json::render(&cached);
} else if !ctx.suppress_human() {
output::table::render(&cached);
}
return Ok(0);
} else {
let err = errors::SearchError::Config(
"No cached results found. Run a search first.".into(),
);
if ctx.is_json() {
output::json::render_error(&err);
} else {
eprintln!("No cached results found. Run a search first.");
}
return Ok(err.exit_code());
}
}
if let Some(ref providers) = args.providers {
const KNOWN: &[&str] = &[
"parallel",
"brave",
"serper",
"exa",
"jina",
"firecrawl",
"tavily",
"serpapi",
"perplexity",
"browserless",
"stealth",
"xai",
];
for p in providers {
if !KNOWN.iter().any(|k| k.eq_ignore_ascii_case(p)) {
let err = errors::SearchError::Config(format!(
"Unknown provider '{}'. Valid: {}",
p,
KNOWN.join(", ")
));
if ctx.is_json() {
output::json::render_error(&err);
} else {
eprintln!("Error: {err}");
}
return Ok(err.exit_code());
}
}
}
let spec = registry::spec(args.mode);
if spec.input == registry::InputKind::Url
&& !(args.query.starts_with("http://") || args.query.starts_with("https://"))
{
let err = errors::SearchError::InvalidInput {
message: format!(
"mode '{}' takes a URL as -q, not a text query. Example: search search -m {} -q https://example.com/page",
args.mode, args.mode
),
};
if ctx.is_json() {
output::json::render_error(&err);
} else {
eprintln!("Error: {err}");
}
return Ok(err.exit_code());
}
let count = args.count.unwrap_or(app.config.settings.count);
let opts = types::SearchOpts {
include_domains: args.domain.unwrap_or_default(),
exclude_domains: args.exclude_domain.unwrap_or_default(),
freshness: args.freshness,
};
let mode_str = args.mode.to_string();
if args.providers.is_none()
&& opts.include_domains.is_empty()
&& opts.exclude_domains.is_empty()
&& opts.freshness.is_none()
{
if let Some((mut cached, age)) = cache::load_query(&args.query, &mode_str, count) {
cached.metadata.cached = true;
cached.metadata.cache_age_secs = Some(age);
if ctx.is_json() {
output::json::render(&cached);
} else if !ctx.suppress_human() {
output::table::render(&cached);
}
return Ok(0);
}
}
let spinner = if !ctx.is_json() && !ctx.quiet {
let sp = indicatif::ProgressBar::new_spinner();
sp.set_style(
indicatif::ProgressStyle::default_spinner()
.tick_strings(&[" ", ". ", ".. ", "...", " ..", " .", " "])
.template(" {spinner:.cyan} searching {msg}")
.unwrap(),
);
let provider_hint = args
.providers
.as_ref()
.map(|p| format!(" via {}", p.join(", ")))
.unwrap_or_default();
sp.set_message(format!(
"\"{}\" [{}{}]",
args.query, args.mode, provider_hint
));
sp.enable_steady_tick(std::time::Duration::from_millis(100));
Some(sp)
} else {
None
};
let response =
engine::run(app, &args.query, args.mode, count, &args.providers, &opts).await;
if let Some(sp) = spinner {
sp.finish_and_clear();
}
let response = response?;
if !response.results.is_empty() {
cache::save_last(&response);
cache::save_query(&args.query, &mode_str, count, &response);
}
logging::log_search(&response);
if ctx.is_json() {
output::json::render(&response);
} else if !ctx.suppress_human() {
output::table::render(&response);
}
Ok(0)
}
Commands::Config { action } => {
match action {
ConfigAction::Show => {
if ctx.is_json() {
let all = providers::build_providers(&app);
let configured: Vec<&str> = all
.iter()
.filter(|p| p.is_configured())
.map(|p| p.name())
.collect();
let info = serde_json::json!({
"version": types::ENVELOPE_VERSION,
"status": "success",
"config_path": config::config_path().to_string_lossy(),
"settings": {
"timeout": app.config.settings.timeout,
"count": app.config.settings.count,
},
"providers_configured": configured,
});
output::json::render_value(&info);
} else if !ctx.suppress_human() {
config_show(&app.config);
}
}
ConfigAction::Set { key, value } => {
config_set(&key, &value)?;
if ctx.is_json() {
output::json::render_value(&serde_json::json!({
"version": "1",
"status": "success",
"key": key,
"message": format!("Set {key}"),
}));
} else if !ctx.suppress_human() {
eprintln!("Set {key}");
}
}
ConfigAction::Check => {
if ctx.is_json() {
let all_providers = providers::build_providers(&app);
let all: Vec<(&str, bool)> = all_providers
.iter()
.map(|p| (p.name(), p.is_configured()))
.collect();
let configured: Vec<&str> =
all.iter().filter(|(_, v)| *v).map(|(k, _)| *k).collect();
let unconfigured: Vec<&str> =
all.iter().filter(|(_, v)| !v).map(|(k, _)| *k).collect();
let total = all.len();
output::json::render_value(&serde_json::json!({
"version": "1",
"status": "success",
"configured_count": configured.len(),
"total_count": total,
"configured": configured,
"unconfigured": unconfigured,
}));
} else if !ctx.suppress_human() {
config_check(&app.config);
}
}
ConfigAction::Path => {
let p = config::config_path();
if ctx.is_json() {
output::json::render_value(&serde_json::json!({
"version": "1",
"status": "success",
"data": {
"path": p.to_string_lossy(),
"exists": p.exists(),
},
}));
} else if !ctx.suppress_human() {
println!("{}", p.display());
if !p.exists() {
use owo_colors::OwoColorize;
println!(" {}", "(file does not exist, using defaults)".dimmed());
}
}
}
}
Ok(0)
}
Commands::AgentInfo => {
let all = providers::build_providers(&app);
let providers_info: Vec<serde_json::Value> = all
.iter()
.map(|p| {
let fs = registry::filter_support(p.name());
serde_json::json!({
"name": p.name(),
"configured": p.is_configured(),
"capabilities": p.capabilities(),
"env_keys": p.env_keys(),
"applies_filters": {
"freshness": fs.freshness,
"domains": fs.domains,
"note": fs.note,
},
})
})
.collect();
let modes_info: Vec<serde_json::Value> = registry::MODES
.iter()
.map(|s| {
serde_json::json!({
"name": s.mode.to_string(),
"description": s.description,
"when_to_use": s.when_to_use,
"input": s.input.as_str(),
"merge": s.merge.as_str(),
"providers": s.providers,
})
})
.collect();
let info = serde_json::json!({
"name": "search",
"version": env!("CARGO_PKG_VERSION"),
"description": env!("CARGO_PKG_DESCRIPTION"),
"commands": ["search", "verify", "usage", "config show", "config set", "config check", "config path", "agent-info", "providers", "skill install", "skill status", "update"],
"command_schemas": {
"search": {
"description": "Search across providers",
"args": [
{"name": "-q/--query", "type": "string", "required": true, "description": "Search query — except in extract/scrape/similar modes, where it must be a full http(s) URL (see modes[].input)"},
],
"options": [
{"name": "-m/--mode", "type": "string", "required": false, "default": "general",
"values": ["general","news","academic","people","deep","extract","similar","scrape","scholar","patents","images","places","social"],
"description": "Search mode — chosen explicitly by the caller; the CLI does NOT infer intent from the query"},
{"name": "-c/--count", "type": "integer", "required": false, "description": "Number of results"},
{"name": "-p/--providers", "type": "string[]", "required": false,
"values": ["parallel","brave","serper","exa","jina","firecrawl","tavily","serpapi","perplexity","browserless","stealth","xai"],
"description": "Comma-separated provider list"},
{"name": "-d/--domain", "type": "string[]", "required": false, "description": "Include only these domains"},
{"name": "--exclude-domain", "type": "string[]", "required": false, "description": "Exclude these domains"},
{"name": "-f/--freshness", "type": "string", "required": false,
"values": ["day","week","month","year"],
"description": "Freshness filter"},
]
},
"verify": {
"description": "Check if email addresses exist via SMTP",
"args": [
{"name": "emails", "type": "string[]", "required": false, "description": "Email addresses to verify"},
],
"options": [
{"name": "-f/--file", "type": "string", "required": false, "description": "Read emails from file (use - for stdin)"},
],
"verdicts": ["valid","invalid","catch_all","unreachable","timeout","syntax_error"],
"notes": "No API key required. Uses direct SMTP."
},
"config show": {"description": "Display current configuration (keys masked)", "args": [], "options": []},
"config set": {
"description": "Set a configuration value",
"args": [
{"name": "key", "type": "string", "required": true, "description": "Config key (e.g. keys.brave, settings.timeout)"},
{"name": "value", "type": "string", "required": true, "description": "Value to set"},
],
"options": []
},
"config check": {"description": "Health-check which providers are configured", "args": [], "options": []},
"config path": {"description": "Show configuration file path", "args": [], "options": []},
"agent-info": {"description": "This manifest", "args": [], "options": []},
"providers": {"description": "List all providers with status and capabilities", "args": [], "options": []},
"skill install": {"description": "Install skill file to agent platforms", "args": [], "options": []},
"skill status": {"description": "Check skill installation status", "args": [], "options": []},
"update": {
"description": "Self-update binary from GitHub Releases",
"args": [],
"options": [
{"name": "--check", "type": "bool", "required": false, "default": false, "description": "Check only, don't install"}
]
},
"usage": {
"description": "Remaining credits/quota for providers that expose a usage API (informational; never disables a provider)",
"args": [],
"options": []
},
},
"global_flags": {
"--json": {"type": "bool", "default": false, "description": "Force JSON output (auto-enabled when piped)"},
"--quiet": {"type": "bool", "default": false, "description": "Suppress informational output"},
"--last": {"type": "bool", "default": false, "description": "Replay last search from cache"},
"--x": {"type": "bool", "default": false, "description": "Search X (Twitter) only"},
"--debug": {"type": "bool", "default": false, "description": "Verbose diagnostics to stderr (also honors RUST_LOG)"},
},
"exit_codes": {
"0": "Success",
"1": "Transient error (API, network) -- retry",
"2": "Config/auth error -- fix setup",
"3": "Bad input -- fix arguments",
"4": "Rate limited -- wait and retry",
},
"envelope": {
"version": "1",
"success": "{ version, status, query, mode, results, answers?, metadata: { elapsed_ms, result_count, providers_queried, providers_failed, providers_cancelled?, provider_results?, warnings?, cached?, cache_age_secs?, provider_failures? } }",
"error": "{ version, status, error: { code, message, suggestion, provider_failures } }",
"statuses": ["success", "partial_success", "no_results"],
"provider_failure": "{ provider, category, http_status, code, reason, retryable }",
"failure_categories": ["auth", "billing_quota", "rate_limit", "timeout", "network", "bad_request", "server", "parse", "config", "other"],
"answers": "AI-synthesized answers (e.g. Perplexity/Tavily) as { provider, text } — kept out of results so every results[].url is a fetchable web URL",
"metadata_notes": "provider_results maps provider -> result count contributed (auditability); providers_cancelled were cut off by the early-stop grace window and neither failed nor contributed; warnings flag filters a provider didn't apply; cached=true marks a replay from the local 5-minute cache",
},
"providers": providers_info,
"modes": modes_info,
"routing": "explicit — the caller picks -m/--mode and/or -p/--providers. There is no automatic intent detection; an unspecified mode is a plain general multi-provider web search. -p overrides the mode's provider set entirely.",
"merge_semantics": "fused modes rank results with reciprocal rank fusion: URLs found by multiple providers score higher, ties broken by per-provider rank. Ordering is deterministic and independent of provider speed. Once enough unique results arrive, still-running providers get a 1.5s grace window and are then cancelled (reported in metadata.providers_cancelled) — except deep mode, which always waits for every provider.",
"config": {
"path": config::config_path().to_string_lossy(),
"env_prefix": "SEARCH_",
"key_precedence": "<PROVIDER>_API_KEY env > SEARCH_KEYS_* env > config file",
},
"auto_json_when_piped": true,
"not_suited_for": {
"github_repos": {
"task": "Searching GitHub repositories, code, issues, or PRs",
"use_instead": "gh search repos <query> [--language=<lang>] [--sort=stars] [--json fullName,description,stargazersCount,url]",
"why": "search uses web crawl, not GitHub's API — no star counts, language filters, or structured repo metadata. gh queries GitHub's search API directly."
},
"github_code": {
"task": "Searching code inside GitHub repositories",
"use_instead": "gh search code <query> [--language=<lang>] [--json path,repository,textMatches]",
"why": "GitHub code search requires GitHub's index, not web search."
},
"github_issues": {
"task": "Searching GitHub issues or pull requests",
"use_instead": "gh search issues <query> [--state=open] [--json title,url,state] or gh search prs <query>",
"why": "GitHub issues/PRs require GitHub's API for state, labels, and metadata."
}
},
});
output::json::render_value(&info);
Ok(0)
}
Commands::Skill { action } => {
match action {
SkillAction::Install => cli::skill::install(ctx),
SkillAction::Status => cli::skill::status(ctx),
}
Ok(0)
}
Commands::Usage => {
let usages = usage::collect(app).await;
if ctx.is_json() {
output::json::render_value(&serde_json::json!({
"version": types::ENVELOPE_VERSION,
"status": "success",
"providers": usages,
"note": "informational only — the CLI never disables a provider based on balance",
}));
} else if !ctx.suppress_human() {
use owo_colors::OwoColorize;
for u in &usages {
if !u.supported {
println!(" {:<12} {}", u.provider, "no usage API".dimmed());
} else if !u.configured {
println!(" {:<12} {}", u.provider, "not configured".yellow());
} else if let Some(err) = &u.error {
println!(" {:<12} {}", u.provider.bold(), err.red());
} else if let Some(data) = &u.data {
let remaining = data
.get("credits_remaining")
.filter(|v| !v.is_null())
.map(|v| v.to_string())
.unwrap_or_else(|| "?".to_string());
let unit = data
.get("unit")
.and_then(|v| v.as_str())
.unwrap_or("credits");
println!(
" {:<12} {} {} remaining",
u.provider.bold(),
remaining.green(),
unit
);
}
}
}
Ok(0)
}
Commands::Providers => {
let all = providers::build_providers(&app);
let provider_info: Vec<(String, bool, Vec<String>)> = all
.iter()
.map(|p| {
(
p.name().to_string(),
p.is_configured(),
p.capabilities().iter().map(|s| s.to_string()).collect(),
)
})
.collect();
if ctx.is_json() {
let json: Vec<serde_json::Value> = provider_info
.iter()
.map(|(name, configured, caps)| {
serde_json::json!({
"name": name,
"configured": configured,
"capabilities": caps,
})
})
.collect();
output::json::render_value(&serde_json::json!({
"version": "1",
"status": "success",
"providers": json,
}));
} else if !ctx.suppress_human() {
output::table::render_providers(&provider_info);
}
Ok(0)
}
Commands::Verify(args) => {
let mut emails: Vec<String> = args.emails;
if let Some(ref path) = args.file {
let content = if path == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(path)?
};
emails.extend(
content
.lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && l.contains('@')),
);
}
if emails.is_empty() {
let err = errors::SearchError::Config(
"No email addresses provided. Usage: search verify user@example.com".into(),
);
if ctx.is_json() {
output::json::render_error(&err);
} else {
eprintln!("Error: {err}");
}
return Ok(2);
}
let start = std::time::Instant::now();
let results = match verify::verify_emails(&emails).await {
Ok(r) => r,
Err(e) => {
if ctx.is_json() {
output::json::render_error(&e);
} else {
eprintln!("Error: {e}");
}
return Ok(e.exit_code());
}
};
let elapsed = start.elapsed().as_millis();
let valid_count = results.iter().filter(|r| r.verdict == "valid").count();
let invalid_count = results.iter().filter(|r| r.verdict == "invalid").count();
let catch_all_count = results.iter().filter(|r| r.verdict == "catch_all").count();
let unprobed = results
.iter()
.filter(|r| r.verdict == "unreachable" || r.verdict == "timeout")
.count();
let syntax_count = results
.iter()
.filter(|r| r.verdict == "syntax_error")
.count();
let total = results.len();
let (status, exit) = if total == 0 {
("no_results", 0)
} else if unprobed == total {
("all_probes_unreachable", 1)
} else if syntax_count == total {
("invalid_input", 3)
} else if unprobed > 0 {
("partial_success", 0)
} else {
("success", 0)
};
let response = serde_json::json!({
"version": types::ENVELOPE_VERSION,
"status": status,
"results": results,
"metadata": {
"elapsed_ms": elapsed,
"verified_count": total,
"valid_count": valid_count,
"invalid_count": invalid_count,
"catch_all_count": catch_all_count,
"unreachable_count": unprobed,
}
});
if ctx.is_json() {
output::json::render_value(&response);
} else if !ctx.suppress_human() {
verify::render_table(&results);
}
Ok(exit)
}
Commands::Update { check } => {
let current = env!("CARGO_PKG_VERSION");
if check {
let res = tokio::task::spawn_blocking(move || {
self_update::backends::github::Update::configure()
.repo_owner("paperfoot")
.repo_name("search-cli")
.bin_name("search")
.current_version(current)
.build()
.and_then(|u| u.get_latest_release())
})
.await
.map_err(|e| errors::SearchError::Config(format!("update task failed: {e}")))?;
match res {
Ok(release) => {
let up_to_date = release.version == current;
if ctx.is_json() {
output::json::render_value(&serde_json::json!({
"version": types::ENVELOPE_VERSION,
"status": "success",
"current_version": current,
"latest_version": release.version,
"update_available": !up_to_date,
}));
} else if !ctx.suppress_human() {
if !up_to_date {
eprintln!("Current version: {current}");
eprintln!("New version available: {}", release.version);
eprintln!("Run `search update` to install");
} else {
eprintln!("Already up to date (v{current})");
}
}
Ok(0)
}
Err(e) => {
let err = errors::SearchError::Api {
provider: "github",
code: "update_check_failed",
message: e.to_string(),
status: None,
};
if ctx.is_json() {
output::json::render_error(&err);
} else {
eprintln!("Could not check for updates: {e}");
}
Ok(err.exit_code())
}
}
} else {
if !ctx.suppress_human() {
eprintln!("Updating search from v{current}...");
}
let res = tokio::task::spawn_blocking(
move || -> Result<_, self_update::errors::Error> {
let latest = self_update::backends::github::Update::configure()
.repo_owner("paperfoot")
.repo_name("search-cli")
.bin_name("search")
.current_version(current)
.build()?
.get_latest_release()?;
self_update::backends::github::Update::configure()
.repo_owner("paperfoot")
.repo_name("search-cli")
.bin_name("search")
.current_version(current)
.target_version_tag(&format!("v{}", latest.version))
.build()?
.update()
},
)
.await
.map_err(|e| errors::SearchError::Config(format!("update task failed: {e}")))?;
match res {
Ok(status) => {
if ctx.is_json() {
output::json::render_value(&serde_json::json!({
"version": types::ENVELOPE_VERSION,
"status": "success",
"updated": status.updated(),
"version_installed": status.version(),
}));
} else if !ctx.suppress_human() {
if status.updated() {
eprintln!("Updated to v{}", status.version());
} else {
eprintln!("Already up to date (v{current})");
}
}
Ok(0)
}
Err(e) => {
let err = errors::SearchError::Config(format!("Update failed: {e}"));
if ctx.is_json() {
output::json::render_error(&err);
} else {
eprintln!("Update failed: {e}");
eprintln!(
"You can update manually: cargo install agent-search --locked"
);
}
Ok(err.exit_code())
}
}
}
}
}
}