use crate::types::Mode;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(
name = "search",
version,
about = "Agent-friendly multi-provider search CLI",
long_about = "Aggregates 13 search providers across 13 explicit search modes.\n\
You choose the mode (-m) and/or providers (-p); the CLI does not guess\n\
intent. Run `search agent-info` for the machine-readable capability map.\n\
Outputs colored tables for humans, JSON when piped to other tools.\n\n\
PROVIDERS:\n \
parallel Agent-native search (Parallel AI): LLM-ready excerpts\n \
brave Independent web index (not Google/Bing) + LLM grounding\n \
serper Cheapest raw Google SERP: web, news, scholar, patents, places\n \
exa Neural/semantic search, LinkedIn people, find-similar\n \
jina Fast web search + URL-to-markdown reader\n \
linkup High-accuracy agent search (SimpleQA leader)\n \
firecrawl JS-rendered page scraping + structured extraction\n \
tavily RAG-oriented search: general, news, academic, deep\n \
serpapi Many engines (Google, Bing, YouTube, Baidu, Scholar)\n \
perplexity LLM-synthesized answer with citations (Sonar)\n \
browserless Cloud browser for Cloudflare/JS-heavy pages\n \
stealth Local anti-bot scraper (no API key)\n \
xai Only real-time X/Twitter search (Grok agentic)\n\n\
EXAMPLES:\n \
search \"rust error handling\" # general web search\n \
search search -q \"CRISPR\" -m academic # academic papers\n \
search search -q \"CEO of Stripe\" -m people # LinkedIn profiles via Exa\n \
search search -q \"AI news\" -m news # breaking news\n \
search search -q \"trending on twitter\" -m social # X/Twitter search\n \
search search -q \"query\" -p exa # force Exa only\n \
search search -q \"query\" -p exa,brave # only Exa + Brave\n \
search --x \"AI agents\" # search X (Twitter) only\n \
search \"query\" --json | jq '.results[].url' # pipe JSON to jq"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(trailing_var_arg = true, global = false)]
pub query_words: Vec<String>,
#[arg(long, global = true)]
pub json: bool,
#[arg(long, global = true)]
pub quiet: bool,
#[arg(long, global = true)]
pub last: bool,
#[arg(long = "x", global = true)]
pub x_only: bool,
#[arg(long, global = true, visible_alias = "verbose")]
pub debug: bool,
}
#[derive(Subcommand)]
pub enum Commands {
Search(SearchArgs),
Config {
#[command(subcommand)]
action: ConfigAction,
},
AgentInfo,
Providers,
Verify(VerifyArgs),
Skill {
#[command(subcommand)]
action: SkillAction,
},
Update {
#[arg(long)]
check: bool,
},
Usage,
}
#[derive(Parser)]
pub struct VerifyArgs {
pub emails: Vec<String>,
#[arg(short, long)]
pub file: Option<String>,
}
#[derive(Parser)]
pub struct SearchArgs {
#[arg(short, long)]
pub query: String,
#[arg(short, long, value_enum, default_value = "general")]
pub mode: Mode,
#[arg(short, long)]
pub count: Option<usize>,
#[arg(short, long, value_delimiter = ',')]
pub providers: Option<Vec<String>>,
#[arg(short, long, value_delimiter = ',')]
pub domain: Option<Vec<String>>,
#[arg(long, value_delimiter = ',')]
pub exclude_domain: Option<Vec<String>>,
#[arg(short, long)]
pub freshness: Option<String>,
}
#[derive(Subcommand)]
pub enum ConfigAction {
Show,
Set {
key: String,
value: String,
},
Check,
Path,
}
#[derive(Subcommand)]
pub enum SkillAction {
Install,
Status,
}
pub mod skill {
use crate::output::Ctx;
use std::path::PathBuf;
const SKILL_CONTENT: &str = include_str!("../SKILL.md");
struct Target {
name: &'static str,
path: PathBuf,
}
fn home() -> PathBuf {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."))
}
fn targets() -> Vec<Target> {
let h = home();
vec![
Target {
name: "Claude Code",
path: h.join(".claude/skills/search"),
},
Target {
name: "Codex CLI",
path: h.join(".codex/skills/search"),
},
Target {
name: "Gemini CLI",
path: h.join(".gemini/skills/search"),
},
]
}
pub fn install(ctx: &Ctx) {
let mut results = Vec::new();
for t in &targets() {
let skill_path = t.path.join("SKILL.md");
let status = if skill_path.exists()
&& std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_CONTENT)
{
"already_current"
} else {
if let Err(e) = std::fs::create_dir_all(&t.path) {
eprintln!(" Failed {}: {e}", t.name);
continue;
}
if let Err(e) = std::fs::write(&skill_path, SKILL_CONTENT) {
eprintln!(" Failed {}: {e}", t.name);
continue;
}
"installed"
};
results.push((t.name, skill_path.display().to_string(), status));
}
if ctx.is_json() {
let items: Vec<serde_json::Value> = results
.iter()
.map(|(name, path, status)| {
serde_json::json!({"platform": name, "path": path, "status": status})
})
.collect();
crate::output::json::render_value(&serde_json::json!({
"version": "1",
"status": "success",
"data": items,
}));
} else if !ctx.suppress_human() {
use owo_colors::OwoColorize;
for (name, path, status) in &results {
let marker = if *status == "installed" { "+" } else { "=" };
println!(" {} {} -> {}", marker.green(), name.bold(), path.dimmed());
}
}
}
pub fn status(ctx: &Ctx) {
let mut results = Vec::new();
for t in &targets() {
let skill_path = t.path.join("SKILL.md");
let (installed, current) = if skill_path.exists() {
let current =
std::fs::read_to_string(&skill_path).is_ok_and(|c| c == SKILL_CONTENT);
(true, current)
} else {
(false, false)
};
results.push((t.name, installed, current));
}
if ctx.is_json() {
let items: Vec<serde_json::Value> = results
.iter()
.map(|(name, installed, current)| {
serde_json::json!({"platform": name, "installed": installed, "current": current})
})
.collect();
crate::output::json::render_value(&serde_json::json!({
"version": "1",
"status": "success",
"data": items,
}));
} else if !ctx.suppress_human() {
use owo_colors::OwoColorize;
for (name, installed, current) in &results {
let status = if *current {
"current".green().to_string()
} else if *installed {
"outdated".yellow().to_string()
} else {
"not installed".red().to_string()
};
println!(" {} {}", name.bold(), status);
}
}
}
}