Skip to main content

hematite/agent/
lms.rs

1use std::io;
2use std::path::PathBuf;
3use std::process::{Command, Stdio};
4
5/// LM Studio CLI Harness for automated lifecycle management.
6/// Ports the "LMS Mastery" patterns from Codex-RS to ensure
7/// Hematite can auto-start and auto-load models.
8pub struct LmsHarness {
9    pub binary_path: Option<PathBuf>,
10}
11
12impl LmsHarness {
13    pub fn new() -> Self {
14        Self {
15            binary_path: Self::find_lms(),
16        }
17    }
18
19    /// Locate the 'lms' binary in PATH or standard installation directories.
20    fn find_lms() -> Option<PathBuf> {
21        // 1. Try PATH via which
22        if let Ok(path) = which::which("lms") {
23            return Some(path);
24        }
25
26        // 2. Platform-specific fallbacks
27        let home = if cfg!(windows) {
28            std::env::var("USERPROFILE").ok()
29        } else {
30            std::env::var("HOME").ok()
31        };
32
33        if let Some(h) = home {
34            let bin_name = if cfg!(windows) { "lms.exe" } else { "lms" };
35            let fallback = PathBuf::from(h)
36                .join(".lmstudio")
37                .join("bin")
38                .join(bin_name);
39            if fallback.exists() {
40                return Some(fallback);
41            }
42        }
43
44        None
45    }
46
47    /// Check if the LM Studio server is responding on the expected port.
48    pub async fn is_server_responding(&self, base_url: &str) -> bool {
49        let client = reqwest::Client::builder()
50            .timeout(std::time::Duration::from_millis(1000))
51            .build()
52            .unwrap_or_default();
53
54        let url = format!("{}/models", base_url.trim_end_matches('/'));
55        match client.get(&url).send().await {
56            Ok(resp) => resp.status().is_success(),
57            Err(_) => false,
58        }
59    }
60
61    /// Attempt to start the LM Studio server if it's not responding.
62    pub fn ensure_server_running(&self) -> io::Result<()> {
63        let Some(ref lms) = self.binary_path else {
64            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
65        };
66
67        // We run this detached/background-ish so it doesn't block Hematite startup.
68        // LM Studio 'server start' is idempotent.
69        let status = Command::new(lms)
70            .args(["server", "start"])
71            .stdout(Stdio::null())
72            .stderr(Stdio::null())
73            .status()?;
74
75        if !status.success() {
76            return Err(io::Error::new(
77                io::ErrorKind::Other,
78                "Failed to start lms server",
79            ));
80        }
81
82        Ok(())
83    }
84
85    /// Get a list of models currently known to LM Studio.
86    pub fn list_models(&self) -> io::Result<Vec<String>> {
87        let Some(ref lms) = self.binary_path else {
88            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
89        };
90
91        let output = Command::new(lms).args(["ls"]).output()?;
92
93        if !output.status.success() {
94            return Err(io::Error::new(
95                io::ErrorKind::Other,
96                "Failed to list models via lms",
97            ));
98        }
99
100        let out_str = String::from_utf8_lossy(&output.stdout);
101        let models = out_str
102            .lines()
103            .filter(|l| !l.is_empty() && !l.starts_with("NAME")) // Skip header
104            .filter_map(|l| l.split_whitespace().next())
105            .map(|s| s.to_string())
106            .collect();
107
108        Ok(models)
109    }
110
111    /// Get a list of models currently loaded in memory.
112    pub fn list_loaded_models(&self) -> io::Result<Vec<String>> {
113        let Some(ref lms) = self.binary_path else {
114            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
115        };
116
117        let output = Command::new(lms).args(["ps"]).output()?;
118
119        if !output.status.success() {
120            return Err(io::Error::new(
121                io::ErrorKind::Other,
122                "Failed to list loaded models via lms",
123            ));
124        }
125
126        let out_str = String::from_utf8_lossy(&output.stdout);
127        let models = out_str
128            .lines()
129            .filter(|line| !line.is_empty() && !line.starts_with("NAME"))
130            .filter_map(|line| line.split_whitespace().next())
131            .map(|value| value.to_string())
132            .collect();
133
134        Ok(models)
135    }
136
137    /// Load a specific model into the server.
138    pub fn load_model(&self, model_id: &str) -> io::Result<()> {
139        let Some(ref lms) = self.binary_path else {
140            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
141        };
142
143        let status = Command::new(lms)
144            .args(["load", model_id])
145            .stdout(Stdio::null())
146            .stderr(Stdio::null())
147            .status()?;
148
149        if !status.success() {
150            return Err(io::Error::new(
151                io::ErrorKind::Other,
152                format!("Failed to load model: {}", model_id),
153            ));
154        }
155
156        Ok(())
157    }
158
159    /// Unload a specific model from the server.
160    pub fn unload_model(&self, model_id: &str) -> io::Result<()> {
161        let Some(ref lms) = self.binary_path else {
162            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
163        };
164
165        let status = Command::new(lms)
166            .args(["unload", model_id])
167            .stdout(Stdio::null())
168            .stderr(Stdio::null())
169            .status()?;
170
171        if !status.success() {
172            return Err(io::Error::new(
173                io::ErrorKind::Other,
174                format!("Failed to unload model: {}", model_id),
175            ));
176        }
177
178        Ok(())
179    }
180
181    /// Unload all loaded models from the server.
182    pub fn unload_all_models(&self) -> io::Result<()> {
183        let Some(ref lms) = self.binary_path else {
184            return Err(io::Error::new(io::ErrorKind::NotFound, "lms CLI not found"));
185        };
186
187        let status = Command::new(lms)
188            .args(["unload", "--all"])
189            .stdout(Stdio::null())
190            .stderr(Stdio::null())
191            .status()?;
192
193        if !status.success() {
194            return Err(io::Error::new(
195                io::ErrorKind::Other,
196                "Failed to unload all models",
197            ));
198        }
199
200        Ok(())
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn test_lms_discovery() {
210        let harness = LmsHarness::new();
211        // We can't guarantee 'lms' is on the test machine, but we can verify the fallback path logic.
212        if let Some(path) = harness.binary_path {
213            assert!(path.exists());
214        }
215    }
216}