use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(
name = "microresolve",
version,
about = "Pre-LLM decision layer: intent classification, tool selection, request triage.",
long_about = "MicroResolve — a microsecond classical classifier for the pre-LLM decision layer.\n\n\
Run without arguments to start the server with defaults (http://localhost:3001).\n\
Set up persistent config (API keys, etc.) with: microresolve config\n\n\
Homepage: https://github.com/gladius/microresolve"
)]
pub struct Cli {
#[arg(long, value_name = "PORT")]
pub port: Option<u16>,
#[arg(long, value_name = "HOST")]
pub host: Option<String>,
#[arg(long, value_name = "DIR")]
pub data: Option<PathBuf>,
#[arg(long, value_name = "KEY")]
pub llm_key: Option<String>,
#[arg(long, value_name = "PROVIDER")]
pub llm_provider: Option<String>,
#[arg(long, value_name = "MODEL")]
pub llm_model: Option<String>,
#[arg(long)]
pub no_open: bool,
#[arg(long)]
pub print_config: bool,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand, Debug)]
pub enum Command {
Config,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ConfigFile {
pub port: Option<u16>,
pub host: Option<String>,
pub data_dir: Option<PathBuf>,
pub llm_provider: Option<String>,
pub llm_model: Option<String>,
pub llm_api_key: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
pub port: u16,
pub host: String,
pub data_dir: PathBuf,
pub llm_provider: String,
pub llm_model: String,
pub llm_api_key: Option<String>,
pub no_open: bool,
}
pub fn config_path() -> Option<PathBuf> {
directories::ProjectDirs::from("sh", "gladius", "microresolve")
.map(|pd| pd.config_dir().join("config.toml"))
}
pub fn default_data_dir() -> PathBuf {
directories::ProjectDirs::from("sh", "gladius", "microresolve")
.map(|pd| pd.data_dir().to_path_buf())
.unwrap_or_else(|| PathBuf::from("./microresolve-data"))
}
pub fn load_config_file() -> ConfigFile {
let Some(path) = config_path() else {
return ConfigFile::default();
};
let Ok(content) = std::fs::read_to_string(&path) else {
return ConfigFile::default();
};
toml::from_str::<ConfigFile>(&content).unwrap_or_default()
}
pub fn resolve(cli: &Cli) -> ResolvedConfig {
let file = load_config_file();
let port = cli
.port
.or_else(|| {
std::env::var("MICRORESOLVE_PORT")
.ok()
.and_then(|v| v.parse().ok())
})
.or(file.port)
.unwrap_or(3001);
let host = cli
.host
.clone()
.or_else(|| std::env::var("MICRORESOLVE_HOST").ok())
.or(file.host)
.unwrap_or_else(|| "0.0.0.0".to_string());
let data_dir = cli
.data
.clone()
.or_else(|| {
std::env::var("MICRORESOLVE_DATA_DIR")
.ok()
.map(PathBuf::from)
})
.or(file.data_dir)
.unwrap_or_else(default_data_dir);
let llm_provider = cli
.llm_provider
.clone()
.or_else(|| std::env::var("LLM_PROVIDER").ok())
.or(file.llm_provider)
.unwrap_or_else(|| "anthropic".to_string());
let llm_model = cli
.llm_model
.clone()
.or_else(|| std::env::var("LLM_MODEL").ok())
.or(file.llm_model)
.unwrap_or_else(|| match llm_provider.as_str() {
"gemini" => "gemini-2.5-flash".to_string(),
"openai" => "gpt-4o-mini".to_string(),
_ => "claude-haiku-4-5-20251001".to_string(),
});
let llm_api_key = cli
.llm_key
.clone()
.or_else(|| std::env::var("LLM_API_KEY").ok())
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.or(file.llm_api_key);
ResolvedConfig {
port,
host,
data_dir,
llm_provider,
llm_model,
llm_api_key,
no_open: cli.no_open,
}
}
pub fn print_resolved(cfg: &ResolvedConfig) {
println!("Resolved configuration:");
println!(" host = {}", cfg.host);
println!(" port = {}", cfg.port);
println!(" data_dir = {}", cfg.data_dir.display());
println!(" llm_provider = {}", cfg.llm_provider);
println!(" llm_model = {}", cfg.llm_model);
println!(
" llm_api_key = {}",
if cfg.llm_api_key.is_some() {
"(set, hidden)"
} else {
"(not set — training features disabled)"
}
);
if let Some(p) = config_path() {
println!(" config_file = {}", p.display());
}
}
pub fn run_config_subcommand() -> std::io::Result<()> {
use std::io::{BufRead, Write};
let path = config_path().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"could not determine config directory",
)
})?;
println!("MicroResolve configuration setup");
println!("Will write to: {}\n", path.display());
let existing = load_config_file();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut stdin = stdin.lock();
let mut stdout = stdout.lock();
let mut buf = String::new();
let prompt = |stdout: &mut std::io::StdoutLock,
stdin: &mut std::io::StdinLock,
buf: &mut String,
label: &str,
current: Option<&str>|
-> std::io::Result<Option<String>> {
match current {
Some(c) => write!(stdout, "{} [{}]: ", label, c)?,
None => write!(stdout, "{}: ", label)?,
}
stdout.flush()?;
buf.clear();
stdin.read_line(buf)?;
let trimmed = buf.trim();
if trimmed.is_empty() {
Ok(current.map(|s| s.to_string()))
} else {
Ok(Some(trimmed.to_string()))
}
};
let llm_provider = prompt(
&mut stdout,
&mut stdin,
&mut buf,
"LLM provider (anthropic/gemini/openai)",
existing.llm_provider.as_deref().or(Some("anthropic")),
)?;
let llm_model = prompt(
&mut stdout,
&mut stdin,
&mut buf,
"LLM model (leave blank to use provider default)",
existing.llm_model.as_deref(),
)?;
let llm_api_key = prompt(
&mut stdout,
&mut stdin,
&mut buf,
"LLM API key (leave blank to skip — training features will be disabled)",
existing
.llm_api_key
.as_deref()
.map(|_| "(existing, keep as-is)"),
)?;
let llm_api_key = match llm_api_key.as_deref() {
Some("(existing, keep as-is)") => existing.llm_api_key.clone(),
other => other.map(|s| s.to_string()),
};
let port = prompt(
&mut stdout,
&mut stdin,
&mut buf,
"Port (blank = 3001)",
existing.port.map(|p| p.to_string()).as_deref(),
)?
.and_then(|s| s.parse().ok());
let data_dir = prompt(
&mut stdout,
&mut stdin,
&mut buf,
"Data directory (blank = default)",
existing
.data_dir
.as_ref()
.map(|p| p.display().to_string())
.as_deref(),
)?
.map(PathBuf::from);
let new_config = ConfigFile {
port,
host: existing.host,
data_dir,
llm_provider,
llm_model,
llm_api_key,
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let toml_str = toml::to_string_pretty(&new_config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e.to_string()))?;
std::fs::write(&path, toml_str)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
println!("\nWrote {}", path.display());
Ok(())
}
pub fn looks_headless() -> bool {
if std::env::var_os("SSH_CONNECTION").is_some()
|| std::env::var_os("SSH_CLIENT").is_some()
|| std::env::var_os("SSH_TTY").is_some()
{
return true;
}
if std::env::var_os("CI").is_some() || std::env::var_os("GITHUB_ACTIONS").is_some() {
return true;
}
#[cfg(target_os = "linux")]
{
if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
return true;
}
}
false
}