155 lines
5.3 KiB
Rust
155 lines
5.3 KiB
Rust
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<Vec<ProviderConfig>> {
|
|
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<String> {
|
|
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<String> {
|
|
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");
|
|
}
|
|
}
|