use anyhow::Result;
use clap::{Parser, Subcommand};
pub mod auth;
pub mod config;
pub mod db;
pub mod dto;
pub mod inference;
pub mod installer;
pub mod os_detector;
pub mod robustness;
pub mod runner;
pub mod tokenizer;
pub mod updater;
#[derive(Parser, Debug)]
#[command(
name = "cmdh",
about = "cmdh — the CmdHub CLI client for offline command search and execution",
version
)]
pub struct Cli {
#[arg(short, long, global = true, help = "Custom configuration file path")]
pub config: Option<std::path::PathBuf>,
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug, Clone)]
pub enum Commands {
Search {
query: String,
#[arg(long, default_value_t = 5)]
limit: usize,
#[arg(short, long, group = "output_format")]
full: bool,
#[arg(short, long, group = "output_format")]
usage_only: bool,
#[arg(short, long, group = "output_format")]
minimal: bool,
},
Update {
#[arg(long)]
force: bool,
},
Run {
cmd_path: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
#[arg(short, long)]
yes: bool,
},
Install {
#[command(subcommand)]
sub: InstallAction,
},
Init {
#[arg(long)]
force: bool,
},
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Login,
Logout,
}
#[derive(Subcommand, Debug, Clone)]
pub enum InstallAction {
Vector {
#[arg(long, value_name = "FILE")]
from_file: Option<std::path::PathBuf>,
#[arg(long)]
force: bool,
},
}
pub async fn run() -> Result<()> {
let cli = Cli::parse();
if let Commands::Init { force } = cli.command {
let config_dir = config::get_config_dir();
let config_path = config_dir.join("config.toml");
if config_path.exists() && !force {
eprintln!(
"Warning: Configuration file already exists at {:?}",
config_path
);
return Ok(());
}
std::fs::create_dir_all(&config_dir)?;
let detected = os_detector::detect_os().unwrap_or_else(|| "unknown".to_string());
let default_key: String = config::OFFICIAL_PUBLIC_KEY
.iter()
.map(|b| format!("{:02x}", b))
.collect();
let config_content = format!(
r#"# CmdHub configuration file
api_url = "https://cdn.cmdhub.org"
public_key = "{default_key}"
timeout_seconds = 30
[output]
# Set the format of the search results output to stdout.
# Supported modes:
# - "full" : Returns the full command contract including descriptions, risks, and install commands.
# - "usage" : Returns a slim template format focusing purely on path and execution usage structure.
# - "minimal": Returns only the command pathway (e.g. [{{"cmd_path":"git"}}]).
mode = "full"
[install]
# Host operating system override.
# Detected on your platform as: "{detected}"
# To override manually, uncomment the line below:
# os = "{detected}"
# Priority sequence when searching for package manager installer instructions.
# The resolver checks system installers first (matching your OS release),
# then traverses these developer packages in order.
package_managers = ["uv", "npm", "cargo", "go"]
"#
);
std::fs::write(&config_path, config_content)?;
println!(
"Configuration initialized successfully at {:?}",
config_path
);
return Ok(());
}
let config = config::load_or_create_config(cli.config.clone())?;
let conn = {
let is_force_update = matches!(cli.command, Commands::Update { force: true });
if !is_force_update {
if let Err(e) = db::hydrate_starter_if_empty() {
eprintln!("Warning: could not seed starter database ({e}).");
}
}
let try_open_init = || -> Result<rusqlite::Connection> {
let c = db::open_db()?;
db::init_db(&c)?;
Ok(c)
};
match try_open_init() {
Ok(c) => c,
Err(e) if is_force_update => {
eprintln!(
"Warning: database is corrupt or missing ({}), deleting and recreating...",
e
);
let db_path = db::resolve_db_path();
if db_path.exists() {
std::fs::remove_file(&db_path)?;
}
let c = db::open_db()?;
db::init_db(&c)?;
c
}
Err(e) => return Err(e),
}
};
match cli.command {
Commands::Search {
query,
limit,
full,
usage_only,
minimal,
} => {
let robustness_query = robustness::preprocess_robustness(&query);
let mut query_vector = None;
match installer::ensure_model_installed(&config).await {
Ok(model_path) => {
if let Ok(model) = inference::EmbeddingModel::load(&model_path) {
let tokenizer = tokenizer::Tokenizer::new();
let (ids, mask) = tokenizer.tokenize_query(&robustness_query);
if let Ok(vec) = model.generate_embedding(&ids, &mask) {
query_vector = Some(vec);
}
}
}
Err(e) => {
eprintln!(
"Warning: Local semantic search is inactive ({}). Falling back to full-text search.",
e
);
}
}
let results = db::search_all(&conn, &robustness_query, query_vector.as_deref(), limit)?;
let is_none = results.iter().any(|r| r.confidence == "none");
if is_none {
let is_test = std::env::var("CMDH_TEST").is_ok()
|| (std::env::var("CARGO_MANIFEST_DIR").is_ok()
&& std::env::var("CMDH_OOD_GATE").is_err());
if !is_test {
eprintln!("No confident match for \"{}\". (out-of-domain)", query);
println!("[]");
std::process::exit(2);
}
}
let mode = if full {
"full"
} else if usage_only {
"usage"
} else if minimal {
"minimal"
} else {
&config.output.mode
};
let json_output = dto::format_results(results, mode, &config);
println!("{}", serde_json::to_string(&json_output)?);
}
Commands::Update { force } => {
updater::update_database(&config, force).await?;
}
Commands::Run {
cmd_path,
args,
yes,
} => {
runner::run_command(&config, &conn, &cmd_path, &args, yes)?;
}
Commands::Install { sub } => match sub {
InstallAction::Vector { from_file, force } => {
installer::install_vector(&config, from_file, force).await?;
}
},
Commands::Completions { shell } => {
use clap::CommandFactory;
let mut cmd = Cli::command();
clap_complete::generate(shell, &mut cmd, "cmdh", &mut std::io::stdout());
}
Commands::Login => {
auth::login_flow(&config).await?;
}
Commands::Logout => {
auth::logout_flow(&config).await?;
}
Commands::Init { .. } => unreachable!(),
}
Ok(())
}