use anyhow::Result; use crate::config::{DriverKind, ProviderConfig}; use crate::session::transport::{Launch, Transport}; const PROBES: &[(&str, &str, DriverKind)] = &[ ("claude-cli", "claude", DriverKind::ClaudeCli), ("llama-cpp", "llama-server", DriverKind::LlamaCpp), ]; const CLAUDE_MODELS: &[&str] = &["fable", "opus", "sonnet", "haiku"]; /// One round trip rather than one per program: over ssh each would be a separate /// connection and handshake. `command -v` is POSIX and a shell builtin, so it /// works whatever is installed -- and `|| true` keeps a missing program from /// ending the loop, since the caller wants the whole answer. pub async fn discover(transport: &Transport) -> Result> { let wanted: Vec<&str> = PROBES.iter().map(|(_, binary, _)| *binary).collect(); let script = format!( "for p in {}; do command -v \"$p\" || true; done", wanted.join(" ") ); let launch = Launch::new("sh", vec!["-c".to_string(), script], None); let found = transport.capture(&launch).await.map_err(explain)?; let mut providers = Vec::new(); if matches!(transport, Transport::Here) { providers.push(ProviderConfig { name: crate::config::ECHO_PROVIDER.to_string(), kind: DriverKind::Echo, command: None, models: Vec::new(), }); } for (name, binary, kind) in PROBES { let path = found .lines() .map(str::trim) .find(|line| line.rsplit('/').next() == Some(*binary)); let Some(path) = path else { continue; }; providers.push(ProviderConfig { name: (*name).to_string(), kind: *kind, command: Some(path.to_string()), models: match kind { DriverKind::ClaudeCli => CLAUDE_MODELS.iter().map(|m| (*m).to_string()).collect(), _ => Vec::new(), }, }); } Ok(providers) } fn explain(err: anyhow::Error) -> anyhow::Error { let message = format!("{err:#}"); if message.contains("Host key verification failed") { return anyhow::anyhow!( "{message} This machine has not been connected to before, so its key is not \ trusted yet. Ssh to it once from the backend -- that is where the decision to \ trust a key belongs -- and try again.", ); } if message.contains("Permission denied") { return anyhow::anyhow!( "{message} The key named here has to be authorized on that machine, and the path \ is read on the backend rather than on the phone.", ); } err } /// A short, stable, filename-safe id derived from a label. Derived once when a /// setup is added and then fixed, so the label stays editable. Collisions are /// resolved by the caller, which is the only place that knows what exists. pub fn id_from(label: &str) -> String { let slug: String = label .chars() .map(|c| { if c.is_ascii_alphanumeric() { c.to_ascii_lowercase() } else { '-' } }) .collect(); let slug = slug.trim_matches('-').replace("--", "-"); if slug.is_empty() { crate::session::random_hex() } else { slug.chars().take(32).collect() } } pub fn tidy(value: &str) -> Option { let value = value.trim(); if value.is_empty() { return None; } Some(match value.strip_prefix("~/") { Some(rest) => match std::env::home_dir() { Some(home) => home.join(rest).to_string_lossy().into_owned(), None => value.to_string(), }, None => value.to_string(), }) } pub fn shorten_home(path: &str) -> String { let Some(home) = std::env::home_dir() else { return path.to_string(); }; let home = home.to_string_lossy(); match path.strip_prefix(home.as_ref()) { Some("") => "~".to_string(), Some(rest) if rest.starts_with('/') => format!("~{rest}"), _ => path.to_string(), } } /// The common case of [`Transport::capture_with_input`]: nothing on stdin, a /// failure reported as the machine's own words (ssh's "Permission denied" is the /// useful half of why a setup cannot be reached), and the output read as text /// because every caller here is asking a question whose answer is words. impl Transport { pub async fn capture(&self, launch: &Launch) -> Result { let captured = self .capture_with_input(launch, super::session::transport::Input::None) .await?; Ok(String::from_utf8_lossy(&captured.ok()?).into_owned()) } } #[cfg(test)] mod tests { use super::*; #[test] fn a_home_path_shortens_and_expands_back() { let Some(home) = std::env::home_dir() else { return; }; let full = home.join("repos/ai-app-2"); let full = full.to_string_lossy(); assert_eq!(shorten_home(&full), "~/repos/ai-app-2"); assert_eq!(shorten_home(&home.to_string_lossy()), "~"); assert_eq!(tidy("~/repos/ai-app-2").as_deref(), Some(full.as_ref())); let sibling = format!("{}-backup/notes", home.to_string_lossy()); assert_eq!(shorten_home(&sibling), sibling); assert_eq!(shorten_home("/etc/hosts"), "/etc/hosts"); } }