mod acp;
mod agent;
mod api;
mod app;
mod bench;
mod cli;
mod clipboard;
mod commands;
mod common;
mod config;
mod evolution;
mod evolve;
mod headless;
mod lsp;
mod mcp;
mod optimizer;
mod plugin;
mod project_cache;
mod rag;
mod registry;
mod remote;
mod repo_map;
mod search;
mod security;
#[cfg(feature = "web")]
mod server;
mod skills;
mod telemetry;
mod tools;
mod trust;
mod tui;
mod util;
mod watch;
use anyhow::Result;
use tracing_subscriber::EnvFilter;
async fn init_logging() -> Result<(
std::path::PathBuf,
tracing_appender::non_blocking::WorkerGuard,
)> {
let log_dir = config::logs_dir();
tokio::fs::create_dir_all(&log_dir).await?;
let retain_secs = 7 * 24 * 60 * 60;
if let Ok(mut entries) = tokio::fs::read_dir(&log_dir).await {
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with("collet.") && name.ends_with(".log") {
let old = entry
.metadata()
.await
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.map(|d| d.as_secs() > retain_secs)
.unwrap_or(false);
if old {
let _ = tokio::fs::remove_file(entry.path()).await;
}
}
}
}
let (writer, guard) = tracing_appender::non_blocking(
tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("collet")
.filename_suffix("log")
.build(&log_dir)
.unwrap_or_else(|_| {
tracing_appender::rolling::never(&log_dir, "collet.log")
}),
);
tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.with_writer(writer)
.with_ansi(false)
.init();
Ok((log_dir, guard))
}
async fn try_dispatch_subcommand(args: &[String]) -> Option<Result<()>> {
let first = args.get(1).map(|s| s.as_str())?;
let sub_help = cli::is_help_arg(args.get(2));
match first {
"setup" => {
if sub_help {
cli::print_setup_usage();
return Some(Ok(()));
}
let advanced = args.iter().any(|a| a == "--advanced");
Some(config::cmd_setup(advanced).map_err(anyhow::Error::from))
}
"secure" => {
if sub_help {
cli::print_secure_usage();
return Some(Ok(()));
}
Some(config::cmd_secure(args).map_err(anyhow::Error::from))
}
"unsecure" => {
if sub_help {
cli::print_unsecure_usage();
return Some(Ok(()));
}
Some(config::cmd_unsecure(args).map_err(anyhow::Error::from))
}
"status" => {
if sub_help {
cli::print_status_usage();
return Some(Ok(()));
}
Some(config::cmd_status().map_err(anyhow::Error::from))
}
"provider" => {
let sub_args: Vec<String> = args.get(2..).unwrap_or_default().to_vec();
Some(config::cmd_provider(&sub_args).map_err(anyhow::Error::from))
}
"clis" => {
let sub_args: Vec<String> = args.get(2..).unwrap_or_default().to_vec();
Some(config::cmd_clis(&sub_args).map_err(anyhow::Error::from))
}
"help" | "-h" | "--help" => {
cli::print_usage();
Some(Ok(()))
}
"--version" | "-V" | "version" => {
println!("collet {}", env!("CARGO_PKG_VERSION"));
Some(Ok(()))
}
"mcp" => {
let sub_args: Vec<String> = args.get(2..).unwrap_or_default().to_vec();
Some(cli::cmd_mcp(&sub_args))
}
"update" | "--update" | "-U" => {
if sub_help {
cli::print_update_usage();
return Some(Ok(()));
}
Some(cli::cmd_update().await)
}
"web" => {
if sub_help {
cli::print_web_usage();
return Some(Ok(()));
}
Some(cli::cmd_web(args).await)
}
"remote" => {
if sub_help {
cli::print_remote_usage();
return Some(Ok(()));
}
Some(cli::cmd_remote(args).await)
}
"acp" => {
if sub_help {
cli::print_acp_usage();
return Some(Ok(()));
}
Some(cli::cmd_acp(args).await)
}
"evolve" => {
if sub_help {
evolve::print_evolve_usage();
return Some(Ok(()));
}
let sub_args: Vec<String> = args.get(2..).unwrap_or_default().to_vec();
Some(evolve::cmd_evolve(&sub_args).await)
}
"plugin" => {
if sub_help {
plugin::print_plugin_usage();
return Some(Ok(()));
}
let sub_args: Vec<String> = args.get(2..).unwrap_or_default().to_vec();
Some(plugin::cmd_plugin(&sub_args))
}
_ => None,
}
}
fn resolve_model_provider(config: &mut config::Config) {
let model = config.model.clone();
let (provider_name, model_name) = if let Some(idx) = model.find('/') {
(Some(&model[..idx]), &model[idx + 1..])
} else {
(None::<&str>, model.as_str())
};
let apply_provider = |config: &mut config::Config,
entry: &config::ProviderEntry,
api_key: &str,
model_name: &str| {
config.model = model_name.to_string();
if entry.is_cli() {
config.cli = entry.cli.clone();
config.cli_args = entry.cli_args.clone();
} else {
if !entry.base_url.is_empty() {
config.base_url = entry.base_url.clone();
}
if !api_key.is_empty() {
config.api_key = api_key.to_string();
}
config.cli = None;
config.cli_args = Vec::new();
}
};
if let Some(pname) = provider_name {
if let Some((entry, api_key)) = config::resolve_provider(pname) {
apply_provider(config, &entry, &api_key, model_name);
}
} else if let Ok(file) = config::load_config_file()
&& let Some(entry) = file
.providers
.iter()
.find(|pe| pe.all_models().contains(&model_name))
{
let api_key = entry
.api_key_enc
.as_ref()
.filter(|enc| !enc.is_empty())
.and_then(|enc| config::decrypt_key(enc).ok())
.unwrap_or_default();
apply_provider(config, entry, &api_key, model_name);
}
}
#[tokio::main]
async fn main() -> Result<()> {
let (log_dir, _log_guard) = init_logging().await?;
tracing::info!("collet starting up");
let args: Vec<String> = std::env::args().collect();
if let Some(result) = try_dispatch_subcommand(&args).await {
return result;
}
let flags = cli::parse_flags(&args);
if let Some(ref dir) = flags.dir {
let path = std::path::Path::new(dir);
if !path.is_dir() {
eprintln!("❌ --dir: not a directory: {dir}");
std::process::exit(1);
}
if let Err(e) = std::env::set_current_dir(path) {
eprintln!("❌ --dir: {e}");
std::process::exit(1);
}
}
let prompt = cli::extract_prompt(&args);
let has_prompt = prompt.is_some();
let has_session_flag = flags.r#continue || flags.resume;
if let Err(e) = config::run_setup_wizard_if_needed(has_prompt, has_session_flag) {
eprintln!("❌ Setup error: {e}");
std::process::exit(1);
}
let mut config = match config::Config::load() {
Ok(c) => c,
Err(e) => {
eprintln!("❌ {e}");
eprintln!();
eprintln!("Quick start:");
eprintln!(" collet setup && collet secure");
std::process::exit(1);
}
};
if let Some(model) = &flags.model {
config.model = model.clone();
}
if flags.yolo {
config.auto_commit = true;
config.yolo = true;
}
resolve_model_provider(&mut config);
tracing::info!(model = %config.model, base_url = %config.base_url, cli = ?config.cli, "Config loaded");
let _telemetry = telemetry::init(&config);
telemetry::refresh_kill_switch();
_telemetry.track_event(
"session_start",
serde_json::json!({
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"model": &config.model,
}),
);
let client = api::provider::OpenAiCompatibleProvider::from_config(&config)?;
if let Some(prompt) = prompt {
if flags.watch {
let watch_config = watch::WatchConfig {
prompt,
debounce_ms: flags.debounce.unwrap_or(2000),
extensions: flags.ext.clone(),
watch_dir: flags.watch_dir.clone(),
model: flags.model.clone(),
agent: flags.watch_agent.clone(),
};
tracing::info!("Running in watch mode");
return watch::run_watch(config, client, watch_config).await;
}
tracing::info!("Running headless");
return headless::run_headless(config, client, prompt, flags.json_metrics).await;
}
{
let wd = std::env::current_dir()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if trust::load_trust(&wd).is_none() {
trust::prompt_trust(&wd);
}
}
use std::io::IsTerminal as _;
let no_tty = !std::io::stdin().is_terminal();
if !no_tty {
crate::tui::terminal::redirect_stderr(&log_dir);
}
let (splash, splash_tx) = crate::tui::splash::SplashScreen::start();
let make_app = |config, client| {
if no_tty {
app::App::new(config, client)
} else {
app::App::new_with_progress(config, client, &|label| splash_tx.step(label))
}
};
if flags.r#continue {
let working_dir = std::env::current_dir()?.to_string_lossy().to_string();
let session_store = agent::session::SessionStore::new(&working_dir);
if let Some(snapshot) = session_store.find_incomplete().await {
tracing::info!(session_id = %snapshot.session_id, "Auto-resuming last session");
let mut app = match make_app(config, client) {
Ok(a) => a,
Err(e) => {
splash.finish();
return Err(e);
}
};
splash.finish();
app.force_resume(snapshot);
app.run().await?;
tracing::info!("collet shutting down");
return Ok(());
} else {
splash.finish();
eprintln!("ℹ️ No incomplete session found. Starting fresh.");
}
return Ok(());
}
if let Some(ref session_id) = flags.resume_session_id {
let working_dir = std::env::current_dir()?.to_string_lossy().to_string();
let session_store = agent::session::SessionStore::new(&working_dir);
match session_store.load(session_id).await {
Ok(snapshot) => {
tracing::info!(session_id = %snapshot.session_id, "Resuming session by ID");
let mut app = match make_app(config, client) {
Ok(a) => a,
Err(e) => {
splash.finish();
return Err(e);
}
};
splash.finish();
app.force_resume(snapshot);
app.run().await?;
tracing::info!("collet shutting down");
return Ok(());
}
Err(e) => {
splash.finish();
eprintln!("Failed to load session '{session_id}': {e}");
return Ok(());
}
}
}
let mut app = match make_app(config, client) {
Ok(a) => a,
Err(e) => {
splash.finish();
return Err(e);
}
};
splash.finish();
if flags.resume {
app.open_resume_popup = true;
}
app.run().await?;
tracing::info!("collet shutting down");
Ok(())
}