use std::ffi::OsString;
use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use std::time::Duration;
use clap::{Parser, Subcommand, ValueEnum};
use rypsok_core::config::{Config, ConfigError, ConfigSource};
use rypsok_core::limits::defaults::SHUTDOWN_MAX_MS;
use rypsok_core::{CancellationToken, Rypsok, telemetry};
use crate::mcp::{Adapter, stdio};
pub const EXIT_SUCCESS: u8 = 0;
pub const EXIT_CONFIGURATION: u8 = 1;
pub const EXIT_USAGE: u8 = 2;
#[derive(Debug, Parser)]
#[command(
name = "rypsok",
version,
about = "Headless meta-search service for AI agents"
)]
struct Cli {
#[arg(long, global = true, value_name = "PATH")]
config: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Serve {
#[arg(long, value_enum, default_value_t = Transport::Stdio)]
transport: Transport,
},
Check,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Transport {
Stdio,
Http,
}
pub fn run<I, T>(args: I) -> ExitCode
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let cli = match Cli::try_parse_from(args) {
Ok(cli) => cli,
Err(error) => {
let code = if error.use_stderr() {
EXIT_USAGE
} else {
EXIT_SUCCESS
};
let _ = error.print();
return ExitCode::from(code);
}
};
match cli.command {
Command::Serve {
transport: Transport::Http,
} => {
diagnostic("rypsok: `serve --transport http` is not available in this release");
ExitCode::from(EXIT_USAGE)
}
Command::Serve {
transport: Transport::Stdio,
} => serve(cli.config.as_deref()),
Command::Check => check(cli.config.as_deref()),
}
}
fn diagnostic(line: &str) {
let mut stderr = std::io::stderr().lock();
let _ = writeln!(stderr, "{line}");
}
fn report(error: &ConfigError) -> ExitCode {
for problem in &error.problems {
diagnostic(&format!("rypsok: configuration: {problem}"));
}
ExitCode::from(EXIT_CONFIGURATION)
}
fn describe(source: &ConfigSource) -> String {
match source {
ConfigSource::Flag(p) => format!("--config {}", p.display()),
ConfigSource::Env(p) => format!("RYPSOK_CONFIG {}", p.display()),
ConfigSource::Defaults => "defaults (no file)".to_owned(),
}
}
fn check(flag: Option<&std::path::Path>) -> ExitCode {
let (config, source) = match Config::locate(flag) {
Ok(found) => found,
Err(error) => return report(&error),
};
if let Err(error) = config.validate() {
return report(&error);
}
let limits = config.limits();
diagnostic(&format!(
"rypsok: configuration from {} is valid",
describe(&source)
));
diagnostic(&format!(
"rypsok: search: query_max_chars={} max_results={} (1..={}) deadline_ms={} engine_timeout_ms={} reserve_ms={} engines_per_request={} languages={} region={}",
limits.query_max_chars,
limits.max_results,
limits.max_results_ceiling,
limits.search_deadline_ms,
limits.engine_timeout_ms,
limits.search_reserve_ms,
limits.engines_per_request,
config
.search
.languages
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(","),
config.search.region,
));
diagnostic(&format!(
"rypsok: tool rate limits per minute: web_search={} web_fetch={} engine_status={}; engine_status enabled={}; structured_content={}",
limits.web_search_per_minute,
limits.web_fetch_per_minute,
limits.engine_status_per_minute,
config.status.enabled,
config.service.structured_content,
));
for (id, table) in &config.engines {
let credential = match table.secret_source() {
None => "no credential needed".to_owned(),
Some(source) => match source.resolve() {
Ok((_, warning)) => warning.map_or("credential found".to_owned(), |w| {
format!("credential found; {w}")
}),
Err(error) => format!("inactive: {error}"),
},
};
diagnostic(&format!(
"rypsok: engine {id}: type={} enabled={} categories={} preference={} {credential}",
table.kind,
table.enabled,
table
.categories
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(","),
table.preference,
));
}
let categories = config
.served_categories()
.iter()
.map(|c| c.as_str())
.collect::<Vec<_>>()
.join(",");
diagnostic(&format!(
"rypsok: categories served: {}",
if categories.is_empty() {
"none"
} else {
&categories
}
));
ExitCode::from(EXIT_SUCCESS)
}
fn serve(flag: Option<&std::path::Path>) -> ExitCode {
let (config, source) = match Config::locate(flag) {
Ok(found) => found,
Err(error) => return report(&error),
};
if let Err(error) = config.validate() {
return report(&error);
}
telemetry::init(config.logging.level);
tracing::info!(event = "start", version = env!("CARGO_PKG_VERSION"), configuration = %describe(&source), "rypsok starting");
if config.logging.log_queries {
tracing::warn!(
event = "log_queries",
"logging.log_queries is on: query text appears in debug events"
);
}
let service = match Rypsok::new(config) {
Ok(service) => Arc::new(service),
Err(error) => return report(&error),
};
let runtime = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
diagnostic(&format!("rypsok: cannot start the runtime: {error}"));
return ExitCode::from(EXIT_CONFIGURATION);
}
};
let shutdown = CancellationToken::new();
let outcome = runtime.block_on(async {
let signals = shutdown.clone();
tokio::spawn(async move {
wait_for_signal().await;
tracing::info!(event = "signal", "shutdown requested");
signals.cancel();
});
stdio::serve(Adapter::new(service), shutdown).await
});
runtime.shutdown_timeout(Duration::from_millis(u64::from(SHUTDOWN_MAX_MS) / 10));
match outcome {
Ok(()) => ExitCode::from(EXIT_SUCCESS),
Err(error) => {
tracing::error!(event = "serve_failed", error = %error, "serve ended with an error");
ExitCode::from(EXIT_CONFIGURATION)
}
}
}
#[cfg(unix)]
async fn wait_for_signal() {
use tokio::signal::unix::{SignalKind, signal};
let mut term = match signal(SignalKind::terminate()) {
Ok(term) => term,
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
return;
}
};
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
}
#[cfg(not(unix))]
async fn wait_for_signal() {
let _ = tokio::signal::ctrl_c().await;
}
#[cfg(test)]
mod tests;