use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use anyhow::{Context, Result};
use kintsugi_daemon::Client;
fn effective_model() -> Option<(PathBuf, &'static str)> {
if let Some(p) = std::env::var_os("KINTSUGI_MODEL_FILE") {
let p = PathBuf::from(p);
if !p.as_os_str().is_empty() {
return Some((p, "from KINTSUGI_MODEL_FILE"));
}
}
kintsugi_model::config::configured_model().map(|p| (p, "configured"))
}
pub fn status() -> Result<()> {
println!("kintsugi model");
match effective_model() {
Some((path, src)) => {
println!(" configured: {} ({src})", path.display());
if !path.is_file() {
println!(
" ⚠ file is missing — set it again: kintsugi model use <path>"
);
}
}
None => println!(" configured: none — using the heuristic scorer"),
}
if daemon_has_llama() {
println!(" engine: llama.cpp inference available");
} else {
println!(" engine: not built — build it with: kintsugi model install");
}
if Client::is_daemon_running() {
match active_scorer_label() {
Some(label) => println!(" scoring: {label}"),
None => println!(" scoring: (daemon not answering)"),
}
} else {
println!(" scoring: daemon stopped (start it with: kintsugi init)");
}
if let Some((path, _)) = effective_model() {
if path.is_file() && !daemon_has_llama() {
println!();
println!(" A model is configured but this daemon has no inference engine, so it");
println!(" still scores heuristically. Build the engine: kintsugi model install");
}
}
Ok(())
}
pub fn use_model(path: &Path) -> Result<()> {
if !path.is_file() {
anyhow::bail!("not a readable file: {}", path.display());
}
let is_gguf = path
.extension()
.map(|e| e.eq_ignore_ascii_case("gguf"))
.unwrap_or(false);
if !is_gguf {
eprintln!(
"kintsugi: warning — {} is not a .gguf file; the model may fail to load.",
path.display()
);
}
let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
kintsugi_model::config::set_configured_model(&abs)?;
println!("kintsugi: model set to {}", abs.display());
if !daemon_has_llama() {
println!(" note: this daemon has no inference engine yet, so it will keep scoring");
println!(" heuristically until you build it: kintsugi model install");
}
restart_if_running()
}
pub fn remove() -> Result<()> {
kintsugi_model::config::clear_configured_model()?;
if std::env::var_os("KINTSUGI_MODEL_FILE").is_some() {
println!("kintsugi: cleared the configured model.");
println!(
" note: KINTSUGI_MODEL_FILE is still set in your environment and overrides this —"
);
println!(
" unset it (and remove it from your shell profile) to fully fall back to heuristic."
);
} else {
println!("kintsugi: cleared the configured model — falling back to the heuristic scorer.");
}
restart_if_running()
}
pub fn pick() -> Result<()> {
if !daemon_has_llama() {
println!(
"kintsugi: this daemon has no inference engine, so a downloaded model can't run yet."
);
println!(
" It will be downloaded and remembered; build the engine with: kintsugi model install"
);
}
run_remote_script(crate::PICKER_URL, &[]).context("run the model picker")?;
adopt_downloaded_model()
}
pub fn install() -> Result<()> {
println!("kintsugi: setting up the local model — building the engine and downloading a GGUF.");
println!(" This compiles llama.cpp once (a few minutes) and needs a C/C++ toolchain.");
run_remote_script(crate::INSTALL_URL, &["--with-model", "--no-init"])
.context("run the installer's model setup")?;
adopt_downloaded_model()
}
fn adopt_downloaded_model() -> Result<()> {
let dir = picker_model_dir();
match newest_gguf(&dir) {
Some(model) => {
kintsugi_model::config::set_configured_model(&model)?;
println!("kintsugi: model set to {}", model.display());
restart_if_running()
}
None => {
eprintln!(
"kintsugi: no .gguf found in {} — nothing to load.",
dir.display()
);
Ok(())
}
}
}
fn restart_if_running() -> Result<()> {
if !Client::is_daemon_running() {
println!(" • daemon not running — it will load the model on next start: kintsugi init");
return Ok(());
}
println!(" • restarting the daemon to apply the change…");
crate::cmd_stop()?;
for _ in 0..150 {
if !Client::is_daemon_running() {
break;
}
std::thread::sleep(Duration::from_millis(20));
}
if Client::is_daemon_running() {
anyhow::bail!(
"the daemon did not stop (admin-locked?). Unlock it, then: kintsugi stop && kintsugi init"
);
}
crate::start_daemon()?;
if let Some(label) = active_scorer_label() {
println!(" ✓ scoring with: {label}");
}
Ok(())
}
fn run_remote_script(url: &str, args: &[&str]) -> Result<()> {
let script = crate::http_get(url).with_context(|| format!("download {url}"))?;
let tmp = std::env::temp_dir().join(format!("kintsugi-model-{}.sh", std::process::id()));
std::fs::write(&tmp, &script).with_context(|| format!("write {}", tmp.display()))?;
let status = Command::new("sh")
.arg(&tmp)
.args(args)
.status()
.with_context(|| format!("run {}", tmp.display()));
let _ = std::fs::remove_file(&tmp);
let status = status?;
if !status.success() {
anyhow::bail!("the setup script exited with {status}");
}
Ok(())
}
fn picker_model_dir() -> PathBuf {
if let Ok(d) = std::env::var("KINTSUGI_MODEL_DIR") {
return PathBuf::from(d);
}
if let Ok(d) = std::env::var("KINTSUGI_DATA_DIR") {
return PathBuf::from(d).join("models");
}
if let Some(home) = crate::home_dir() {
return home.join(".local/share/kintsugi/models");
}
std::env::temp_dir().join("kintsugi-models")
}
fn newest_gguf(dir: &Path) -> Option<PathBuf> {
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
for entry in std::fs::read_dir(dir).ok()?.flatten() {
let path = entry.path();
let is_gguf = path
.extension()
.map(|e| e.eq_ignore_ascii_case("gguf"))
.unwrap_or(false);
if !is_gguf {
continue;
}
let Ok(mtime) = entry.metadata().and_then(|m| m.modified()) else {
continue;
};
if newest.as_ref().map(|(t, _)| mtime > *t).unwrap_or(true) {
newest = Some((mtime, path));
}
}
newest.map(|(_, p)| p)
}
use crate::{active_scorer_label, daemon_has_llama};
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|e| e.into_inner())
}
fn touch(path: &Path) {
std::fs::write(path, b"x").unwrap();
}
#[test]
fn newest_gguf_picks_most_recent_and_ignores_others() {
let tmp = tempfile::tempdir().unwrap();
let d = tmp.path();
touch(&d.join("notes.txt"));
let old = d.join("old.gguf");
touch(&old);
std::thread::sleep(Duration::from_millis(20));
let new = d.join("new.gguf");
touch(&new);
assert_eq!(newest_gguf(d), Some(new));
}
#[test]
fn newest_gguf_none_when_empty_or_missing() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(newest_gguf(tmp.path()), None);
assert_eq!(newest_gguf(&tmp.path().join("nope")), None);
}
#[test]
fn picker_model_dir_honors_overrides() {
let _g = env_lock();
std::env::set_var("KINTSUGI_MODEL_DIR", "/tmp/explicit");
assert_eq!(picker_model_dir(), PathBuf::from("/tmp/explicit"));
std::env::remove_var("KINTSUGI_MODEL_DIR");
std::env::set_var("KINTSUGI_DATA_DIR", "/tmp/data");
assert_eq!(picker_model_dir(), PathBuf::from("/tmp/data/models"));
std::env::remove_var("KINTSUGI_DATA_DIR");
}
#[test]
fn effective_model_prefers_env_then_config() {
let _g = env_lock();
let tmp = tempfile::tempdir().unwrap();
std::env::set_var("KINTSUGI_DATA_DIR", tmp.path());
std::env::remove_var("KINTSUGI_MODEL_FILE");
kintsugi_model::config::clear_configured_model().unwrap();
assert!(effective_model().is_none());
kintsugi_model::config::set_configured_model(Path::new("/m/cfg.gguf")).unwrap();
let (p, src) = effective_model().unwrap();
assert_eq!(p, PathBuf::from("/m/cfg.gguf"));
assert_eq!(src, "configured");
std::env::set_var("KINTSUGI_MODEL_FILE", "/m/env.gguf");
let (p, src) = effective_model().unwrap();
assert_eq!(p, PathBuf::from("/m/env.gguf"));
assert_eq!(src, "from KINTSUGI_MODEL_FILE");
std::env::remove_var("KINTSUGI_MODEL_FILE");
std::env::remove_var("KINTSUGI_DATA_DIR");
}
}