@hej / sigit / commits / 0b0a731

Add local model discovery and selection helpers

- Discover GGUF models in Onde app group and Hugging Face cache - Persist and restore last selected model for startup - Update tool and agent rules to always run git commands directly - Improve run_command to default cwd to user home directory

paydii committed Apr 24, 2026 at 20:36 UTC 0b0a7310bb1b75bee008995d6fc9d13df62427eb
3 files changed +686 -14
src/main.rs
+25
@@ -105,6 +105,26 @@ smbCloud context you should know and use when it helps:
105 crate boundaries, existing Rails conventions, and existing command flows over \
106 inventing new abstractions
107
108 +CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \
109 +When the user asks you to clone a repo, run a build, check git status, or do \
110 +anything that involves a shell command, you MUST call the run_command tool and \
111 +execute it yourself. Do not print shell commands for the user to copy-paste. \
112 +Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \
113 +If a command fails, try to fix the problem and re-run it. If you cannot fix it \
114 +after two attempts, explain what went wrong and what you tried.
115 +
116 +Git operations — always use run_command:
117 +- git clone: always pass the full absolute destination path as the last argument \
118 + and set cwd to an existing writable parent directory. Example: \
119 + run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \
120 + \"cwd\": \"/Users/me/Repositories\"})
121 +- git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \
122 + stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \
123 + the repo root
124 +- never run git clone without an explicit absolute destination path
125 +- if a clone or init fails, check the error, fix the cause (wrong path, missing \
126 + directory, permissions), and retry
127 +
128 Never introduce yourself unless asked. Jump straight into the answer. \
129 Keep answers short. Write idiomatic code. \
130 Fix root causes, not symptoms.
@@ -122,6 +142,9 @@ Tool-use heuristics:
142 - prefer absolute paths over relative paths when you mention, return, or pass \
143 file and directory paths
144 - if a path does not exist yet, create the directory before creating files in it
145 +- if the user asks to clone a repo, immediately call run_command with git clone \
146 + and an absolute destination path — do not ask where to put it unless the \
147 + request is ambiguous; default to the user's home Repositories directory
148 - if the user asks for a new repo, scaffold, or scratch project, create the \
149 directory, create the first files, and run `git init` without waiting unless \
150 the request says otherwise
@@ -137,6 +160,8 @@ Tool-use heuristics:
160 widen to broader checks if needed
161 - use git commands naturally for status checks, repo setup, diffs, and normal \
162 developer workflows when they help move the task forward
163 +- if a tool call fails, read the error, try to fix it, and retry — do not \
164 + fall back to telling the user what to type
165
166 When the repo is not about smbCloud, act like a normal coding agent and do not \
167 force smbCloud-specific advice into the answer. When it is about smbCloud, be \
src/setup.rs
+641 -2
@@ -1,4 +1,5 @@
1 -//! Shared model cache setup.
1 +//! Shared model cache setup, local model discovery, and lightweight local
2 +//! preferences.
3 //!
4 //! On macOS, siGit desktop and other Onde apps keep their HuggingFace models
5 //! in a shared App Group container at:
@@ -9,10 +10,17 @@
10 //! whatever the desktop app already downloaded (and vice versa). On Linux
11 //! and Windows the default `~/.cache/huggingface/` path is used.
12 //!
13 +//! It also exposes helpers for finding locally available models. Discovery
14 +//! checks the Onde app group first on macOS, then falls back to the normal
15 +//! Hugging Face cache layout.
16 +//!
17 +//! The selected model name is persisted in a small local preferences file so
18 +//! the interactive UI can restore the last choice on the next launch.
19 +//!
20 //! Call this before anything touches `ChatEngine` or `hf-hub` — they read
21 //! the env vars once at init and never check again.
22
15 -use std::path::PathBuf;
23 +use std::path::{Path, PathBuf};
24
25 /// App Group ID shared across all Onde apps (siGit, Rumi, GT8, …).
26 #[cfg(target_os = "macos")]
@@ -58,6 +66,356 @@ pub fn setup_shared_model_cache() {
66 }
67 }
68
69 +/// Preference key used to remember the last selected model.
70 +const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt";
71 +
72 +/// Stable persisted identifier for a selected local model.
73 +#[derive(Debug, Clone, PartialEq, Eq)]
74 +pub struct SelectedModel {
75 + /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
76 + pub model_id: String,
77 + /// GGUF filename inside the snapshot.
78 + pub gguf_file: String,
79 +}
80 +
81 +impl SelectedModel {
82 + fn from_discovered(model: &DiscoveredModel) -> Self {
83 + Self {
84 + model_id: model.model_id.clone(),
85 + gguf_file: model.gguf_file.clone(),
86 + }
87 + }
88 +
89 + fn matches(&self, model: &DiscoveredModel) -> bool {
90 + self.model_id == model.model_id && self.gguf_file == model.gguf_file
91 + }
92 +}
93 +
94 +/// Minimal startup model selection info used before the full UI is running.
95 +#[derive(Debug, Clone, PartialEq, Eq)]
96 +pub struct StartupModelSelection {
97 + /// Human-friendly model name shown in the loading UI.
98 + pub display_name: String,
99 + /// The saved model identifier if one was found.
100 + pub selected_model: Option<SelectedModel>,
101 +}
102 +
103 +/// A locally discovered GGUF model candidate.
104 +#[derive(Debug, Clone, PartialEq, Eq)]
105 +pub struct DiscoveredModel {
106 + /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`.
107 + pub model_id: String,
108 + /// GGUF filename inside the snapshot.
109 + pub gguf_file: String,
110 + /// Human-friendly label shown in model pickers.
111 + pub display_name: String,
112 + /// Absolute path to the snapshot directory that contains the GGUF file.
113 + pub snapshot_path: PathBuf,
114 + /// Absolute path to the GGUF file itself.
115 + pub gguf_path: PathBuf,
116 + /// True when the model came from the Onde app group cache.
117 + pub from_app_group: bool,
118 + /// Whether the snapshot looks complete enough to load.
119 + pub cache_health: ModelCacheHealth,
120 +}
121 +
122 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
123 +pub enum ModelCacheHealth {
124 + Complete,
125 + Incomplete,
126 +}
127 +
128 +/// Return all locally discovered GGUF models.
129 +///
130 +/// Search order:
131 +/// 1. Onde app group cache on macOS
132 +/// 2. Standard Hugging Face cache
133 +pub fn discover_local_models() -> Vec<DiscoveredModel> {
134 + let mut models = Vec::new();
135 +
136 + if let Some(app_group_models) = app_group_models_root() {
137 + collect_models_from_cache_root(&app_group_models, true, &mut models);
138 + }
139 +
140 + if let Some(hf_cache) = hf_cache_root() {
141 + collect_models_from_cache_root(&hf_cache, false, &mut models);
142 + }
143 +
144 + models.sort_by(|left, right| {
145 + left.cache_health
146 + .cmp(&right.cache_health)
147 + .then_with(|| {
148 + left.display_name
149 + .to_lowercase()
150 + .cmp(&right.display_name.to_lowercase())
151 + })
152 + .then_with(|| left.model_id.cmp(&right.model_id))
153 + .then_with(|| left.gguf_file.cmp(&right.gguf_file))
154 + });
155 +
156 + models.dedup_by(|left, right| left.gguf_path == right.gguf_path);
157 + models
158 +}
159 +
160 +fn collect_models_from_cache_root(
161 + cache_root: &Path,
162 + from_app_group: bool,
163 + models: &mut Vec<DiscoveredModel>,
164 +) {
165 + let entries = match std::fs::read_dir(cache_root) {
166 + Ok(entries) => entries,
167 + Err(error) => {
168 + log::debug!(
169 + "Skipping unreadable model cache root {}: {error}",
170 + cache_root.display()
171 + );
172 + return;
173 + }
174 + };
175 +
176 + for entry in entries.flatten() {
177 + let repo_dir = entry.path();
178 + if !repo_dir.is_dir() {
179 + continue;
180 + }
181 +
182 + let dir_name = match entry.file_name().to_str() {
183 + Some(name) => name.to_string(),
184 + None => continue,
185 + };
186 +
187 + if !dir_name.starts_with("models--") {
188 + continue;
189 + }
190 +
191 + let model_id = dir_name["models--".len()..].replace("--", "/");
192 + let snapshots_dir = repo_dir.join("snapshots");
193 + let snapshots = match std::fs::read_dir(&snapshots_dir) {
194 + Ok(entries) => entries,
195 + Err(_) => continue,
196 + };
197 +
198 + for snapshot in snapshots.flatten() {
199 + let snapshot_path = snapshot.path();
200 + if !snapshot_path.is_dir() {
201 + continue;
202 + }
203 +
204 + let files = match std::fs::read_dir(&snapshot_path) {
205 + Ok(entries) => entries,
206 + Err(_) => continue,
207 + };
208 +
209 + let mut has_config_json = false;
210 + let mut has_tokenizer = false;
211 + let mut gguf_files = Vec::new();
212 +
213 + for file in files.flatten() {
214 + let file_path = file.path();
215 + if !file_path.is_file() {
216 + continue;
217 + }
218 +
219 + let file_name = match file.file_name().to_str() {
220 + Some(name) => name.to_string(),
221 + None => continue,
222 + };
223 +
224 + if file_name == "config.json" {
225 + has_config_json = true;
226 + }
227 +
228 + if file_name == "tokenizer.json"
229 + || file_name == "tokenizer.model"
230 + || file_name == "tokenizer_config.json"
231 + {
232 + has_tokenizer = true;
233 + }
234 +
235 + let extension = file_path
236 + .extension()
237 + .and_then(|ext| ext.to_str())
238 + .unwrap_or_default();
239 +
240 + if extension.eq_ignore_ascii_case("gguf") {
241 + gguf_files.push((file_name, file_path));
242 + }
243 + }
244 +
245 + let cache_health = if has_config_json && has_tokenizer {
246 + ModelCacheHealth::Complete
247 + } else {
248 + ModelCacheHealth::Incomplete
249 + };
250 +
251 + for (gguf_file, file_path) in gguf_files {
252 + models.push(DiscoveredModel {
253 + display_name: display_name_for_model(&model_id, &gguf_file),
254 + model_id: model_id.clone(),
255 + gguf_file,
256 + snapshot_path: snapshot_path.clone(),
257 + gguf_path: file_path,
258 + from_app_group,
259 + cache_health,
260 + });
261 + }
262 + }
263 + }
264 +}
265 +
266 +fn display_name_for_model(model_id: &str, gguf_file: &str) -> String {
267 + let repo_name = model_id
268 + .rsplit('/')
269 + .next()
270 + .unwrap_or(model_id)
271 + .replace('_', " ");
272 +
273 + let file_name = gguf_file.strip_suffix(".gguf").unwrap_or(gguf_file);
274 +
275 + if file_name.contains(&repo_name.replace(' ', "_")) || file_name.contains(&repo_name) {
276 + repo_name
277 + } else {
278 + format!("{repo_name} — {file_name}")
279 + }
280 +}
281 +
282 +fn app_group_models_root() -> Option<PathBuf> {
283 + resolve_shared_container().map(|dir| dir.join("models").join("hub"))
284 +}
285 +
286 +fn hf_cache_root() -> Option<PathBuf> {
287 + if let Ok(cache) = std::env::var("HF_HUB_CACHE") {
288 + let path = PathBuf::from(cache);
289 + if path.is_dir() {
290 + return Some(path);
291 + }
292 + }
293 +
294 + if let Ok(home) = std::env::var("HF_HOME") {
295 + let path = PathBuf::from(home).join("hub");
296 + if path.is_dir() {
297 + return Some(path);
298 + }
299 + }
300 +
301 + let home = std::env::var("HOME").ok()?;
302 + let path = PathBuf::from(home)
303 + .join(".cache")
304 + .join("huggingface")
305 + .join("hub");
306 +
307 + path.is_dir().then_some(path)
308 +}
309 +
310 +pub fn load_selected_model() -> Option<SelectedModel> {
311 + let path = selected_model_file_path()?;
312 + let contents = std::fs::read_to_string(path).ok()?;
313 + let trimmed = contents.trim();
314 + if trimmed.is_empty() {
315 + return None;
316 + }
317 +
318 + let mut parts = trimmed.splitn(2, '\n');
319 + let model_id = parts.next()?.trim();
320 + let gguf_file = parts.next()?.trim();
321 +
322 + if model_id.is_empty() || gguf_file.is_empty() {
323 + return None;
324 + }
325 +
326 + Some(SelectedModel {
327 + model_id: model_id.to_string(),
328 + gguf_file: gguf_file.to_string(),
329 + })
330 +}
331 +
332 +#[allow(dead_code)]
333 +pub fn load_selected_model_name() -> Option<String> {
334 + let selected = load_selected_model()?;
335 + discover_local_models()
336 + .into_iter()
337 + .find(|model| selected.matches(model))
338 + .map(|model| model.display_name)
339 +}
340 +
341 +/// Pick the model name siGit should try to load at startup.
342 +///
343 +/// Order:
344 +/// 1. saved selection, if it still exists locally
345 +/// 2. first discovered local model (Onde app group first, then HF cache)
346 +/// 3. no selection
347 +///
348 +/// If there is no saved selection but a local model is discovered, persist that
349 +/// fallback choice so ACP mode and the interactive TUI converge on the same
350 +/// startup model on the next launch too.
351 +pub fn startup_model_selection() -> Option<StartupModelSelection> {
352 + let discovered = discover_local_models();
353 +
354 + if let Some(saved_model) = load_selected_model()
355 + && let Some(model) = discovered.iter().find(|model| {
356 + saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete
357 + })
358 + {
359 + return Some(StartupModelSelection {
360 + display_name: model.display_name.clone(),
361 + selected_model: Some(saved_model),
362 + });
363 + }
364 +
365 + discovered
366 + .into_iter()
367 + .find(|model| model.cache_health == ModelCacheHealth::Complete)
368 + .map(|model| {
369 + let selected_model = SelectedModel::from_discovered(&model);
370 + let _ = save_selected_model(&selected_model);
371 + StartupModelSelection {
372 + display_name: model.display_name.clone(),
373 + selected_model: Some(selected_model),
374 + }
375 + })
376 +}
377 +
378 +pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String> {
379 + let path = selected_model_file_path()
380 + .ok_or_else(|| "Could not determine where to store the selected model.".to_string())?;
381 +
382 + if let Some(parent) = path.parent()
383 + && !parent.exists()
384 + {
385 + std::fs::create_dir_all(parent)
386 + .map_err(|error| format!("Could not create preferences directory: {error}"))?;
387 + }
388 +
389 + let contents = format!(
390 + "{}\n{}\n",
391 + selected_model.model_id, selected_model.gguf_file
392 + );
393 +
394 + std::fs::write(&path, contents)
395 + .map_err(|error| format!("Could not save selected model: {error}"))
396 +}
397 +
398 +fn selected_model_file_path() -> Option<PathBuf> {
399 + if let Some(shared_dir) = resolve_shared_container() {
400 + return Some(shared_dir.join(SELECTED_MODEL_FILE_NAME));
401 + }
402 +
403 + if let Ok(home) = std::env::var("HF_HOME") {
404 + let path = PathBuf::from(home);
405 + if path.is_dir() || path.parent().is_some() {
406 + return Some(path.join(SELECTED_MODEL_FILE_NAME));
407 + }
408 + }
409 +
410 + let home = std::env::var("HOME").ok()?;
411 + Some(
412 + PathBuf::from(home)
413 + .join(".cache")
414 + .join("sigit")
415 + .join(SELECTED_MODEL_FILE_NAME),
416 + )
417 +}
418 +
419 /// Look for the App Group container on disk. macOS creates it the first time
420 /// a signed app in the group accesses it, so it only exists if the user has
421 /// launched siGit desktop (or another Onde app) at least once. A plain CLI
@@ -87,3 +445,284 @@ fn resolve_shared_container() -> Option<PathBuf> {
445 fn resolve_shared_container() -> Option<PathBuf> {
446 None
447 }
448 +
449 +#[cfg(test)]
450 +mod tests {
451 + use super::*;
452 + use std::sync::{Mutex, OnceLock};
453 +
454 + fn env_lock() -> &'static Mutex<()> {
455 + static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
456 + LOCK.get_or_init(|| Mutex::new(()))
457 + }
458 +
459 + fn unique_temp_dir(name: &str) -> PathBuf {
460 + let nanos = std::time::SystemTime::now()
461 + .duration_since(std::time::UNIX_EPOCH)
462 + .expect("system time before unix epoch")
463 + .as_nanos();
464 +
465 + std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}"))
466 + }
467 +
468 + fn create_snapshot(
469 + cache_root: &Path,
470 + model_id: &str,
471 + snapshot_name: &str,
472 + gguf_file: &str,
473 + complete: bool,
474 + ) -> PathBuf {
475 + let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--")));
476 + let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name);
477 + std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir");
478 + std::fs::write(snapshot_dir.join(gguf_file), b"gguf").expect("write gguf");
479 +
480 + if complete {
481 + std::fs::write(snapshot_dir.join("config.json"), b"{}").expect("write config");
482 + std::fs::write(snapshot_dir.join("tokenizer.json"), b"{}").expect("write tokenizer");
483 + }
484 +
485 + snapshot_dir
486 + }
487 +
488 + fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T {
489 + let _guard = env_lock().lock().expect("lock env");
490 +
491 + let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE");
492 + let old_hf_home = std::env::var_os("HF_HOME");
493 + let old_home = std::env::var_os("HOME");
494 +
495 + // SAFETY: tests serialize environment mutation with a process-wide mutex.
496 + unsafe {
497 + std::env::set_var("HF_HUB_CACHE", hf_hub_cache);
498 + std::env::set_var("HF_HOME", hf_home);
499 + std::env::set_var("HOME", hf_home);
500 + }
501 +
502 + let result = f();
503 +
504 + // SAFETY: tests serialize environment mutation with a process-wide mutex.
505 + unsafe {
506 + match old_hf_hub_cache {
507 + Some(value) => std::env::set_var("HF_HUB_CACHE", value),
508 + None => std::env::remove_var("HF_HUB_CACHE"),
509 + }
510 + match old_hf_home {
511 + Some(value) => std::env::set_var("HF_HOME", value),
512 + None => std::env::remove_var("HF_HOME"),
513 + }
514 + match old_home {
515 + Some(value) => std::env::set_var("HOME", value),
516 + None => std::env::remove_var("HOME"),
517 + }
518 + }
519 +
520 + result
521 + }
522 +
523 + #[test]
524 + fn discover_local_models_marks_complete_and_incomplete_snapshots() {
525 + let root = unique_temp_dir("discover-health");
526 + let cache_root = root.join("hf-cache");
527 + let hf_home = root.join("hf-home");
528 + std::fs::create_dir_all(&cache_root).expect("create cache root");
529 + std::fs::create_dir_all(&hf_home).expect("create hf home");
530 +
531 + create_snapshot(
532 + &cache_root,
533 + "bartowski/Qwen_Qwen3-4B-GGUF",
534 + "complete",
535 + "Qwen_Qwen3-4B-Q4_K_M.gguf",
536 + true,
537 + );
538 + create_snapshot(
539 + &cache_root,
540 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
541 + "incomplete",
542 + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
543 + false,
544 + );
545 +
546 + let models = with_test_env(&cache_root, &hf_home, discover_local_models);
547 +
548 + assert_eq!(models.len(), 2);
549 +
550 + let complete = models
551 + .iter()
552 + .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF")
553 + .expect("complete model discovered");
554 + assert_eq!(complete.cache_health, ModelCacheHealth::Complete);
555 + assert!(!complete.from_app_group);
556 +
557 + let incomplete = models
558 + .iter()
559 + .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF")
560 + .expect("incomplete model discovered");
561 + assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete);
562 + assert!(!incomplete.from_app_group);
563 +
564 + std::fs::remove_dir_all(root).expect("remove temp dir");
565 + }
566 +
567 + #[test]
568 + fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() {
569 + let root = unique_temp_dir("startup-selection");
570 + let cache_root = root.join("hf-cache");
571 + let hf_home = root.join("hf-home");
572 + std::fs::create_dir_all(&cache_root).expect("create cache root");
573 + std::fs::create_dir_all(&hf_home).expect("create hf home");
574 +
575 + create_snapshot(
576 + &cache_root,
577 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
578 + "broken",
579 + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
580 + false,
581 + );
582 + create_snapshot(
583 + &cache_root,
584 + "bartowski/Qwen_Qwen3-4B-GGUF",
585 + "ready",
586 + "Qwen_Qwen3-4B-Q4_K_M.gguf",
587 + true,
588 + );
589 +
590 + let selection = with_test_env(&cache_root, &hf_home, || {
591 + let selected_path = selected_model_file_path().expect("selected model path");
592 + if let Some(parent) = selected_path.parent() {
593 + std::fs::create_dir_all(parent).expect("create selected model parent");
594 + }
595 +
596 + std::fs::write(
597 + &selected_path,
598 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n",
599 + )
600 + .expect("write selected model");
601 +
602 + startup_model_selection().expect("startup selection")
603 + });
604 +
605 + assert_eq!(
606 + selection.display_name,
607 + "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"
608 + );
609 + let selected = selection.selected_model.expect("selected model");
610 + assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF");
611 + assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf");
612 +
613 + std::fs::remove_dir_all(root).expect("remove temp dir");
614 + }
615 +
616 + #[test]
617 + fn discover_empty_cache_returns_no_models() {
618 + let root = unique_temp_dir("discover-empty");
619 + let cache_root = root.join("hf-cache");
620 + let hf_home = root.join("hf-home");
621 + std::fs::create_dir_all(&cache_root).expect("create cache root");
622 + std::fs::create_dir_all(&hf_home).expect("create hf home");
623 +
624 + let models = with_test_env(&cache_root, &hf_home, discover_local_models);
625 + assert!(models.is_empty());
626 +
627 + std::fs::remove_dir_all(root).expect("remove temp dir");
628 + }
629 +
630 + #[test]
631 + fn complete_models_sort_before_incomplete() {
632 + let root = unique_temp_dir("sort-order");
633 + let cache_root = root.join("hf-cache");
634 + let hf_home = root.join("hf-home");
635 + std::fs::create_dir_all(&cache_root).expect("create cache root");
636 + std::fs::create_dir_all(&hf_home).expect("create hf home");
637 +
638 + create_snapshot(
639 + &cache_root,
640 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
641 + "snap1",
642 + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
643 + false,
644 + );
645 + create_snapshot(
646 + &cache_root,
647 + "bartowski/Qwen_Qwen3-4B-GGUF",
648 + "snap2",
649 + "Qwen_Qwen3-4B-Q4_K_M.gguf",
650 + true,
651 + );
652 +
653 + let models = with_test_env(&cache_root, &hf_home, discover_local_models);
654 + assert_eq!(models.len(), 2);
655 + assert_eq!(models[0].cache_health, ModelCacheHealth::Complete);
656 + assert_eq!(models[1].cache_health, ModelCacheHealth::Incomplete);
657 +
658 + std::fs::remove_dir_all(root).expect("remove temp dir");
659 + }
660 +
661 + #[test]
662 + fn load_selected_model_roundtrip() {
663 + let root = unique_temp_dir("persistence-roundtrip");
664 + let hf_home = root.join("hf-home");
665 + std::fs::create_dir_all(&hf_home).expect("create hf home");
666 +
667 + with_test_env(&hf_home, &hf_home, || {
668 + let original = SelectedModel {
669 + model_id: "bartowski/Qwen_Qwen3-4B-GGUF".to_string(),
670 + gguf_file: "Qwen_Qwen3-4B-Q4_K_M.gguf".to_string(),
671 + };
672 +
673 + save_selected_model(&original).expect("save");
674 + let loaded = load_selected_model().expect("load");
675 +
676 + assert_eq!(loaded.model_id, original.model_id);
677 + assert_eq!(loaded.gguf_file, original.gguf_file);
678 + });
679 +
680 + std::fs::remove_dir_all(root).expect("remove temp dir");
681 + }
682 +
683 + #[test]
684 + fn load_selected_model_empty_file_returns_none() {
685 + let root = unique_temp_dir("persistence-empty");
686 + let hf_home = root.join("hf-home");
687 + std::fs::create_dir_all(&hf_home).expect("create hf home");
688 +
689 + with_test_env(&hf_home, &hf_home, || {
690 + let path = selected_model_file_path().expect("path");
691 + if let Some(parent) = path.parent() {
692 + std::fs::create_dir_all(parent).expect("create parent");
693 + }
694 + std::fs::write(&path, "").expect("write empty");
695 +
696 + assert!(load_selected_model().is_none());
697 + });
698 +
699 + std::fs::remove_dir_all(root).expect("remove temp dir");
700 + }
701 +
702 + #[test]
703 + fn display_name_deduplicates_repo_and_file_name() {
704 + // When the file name contains the repo name, only the repo name is shown.
705 + let name = display_name_for_model(
706 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
707 + "Qwen2.5-Coder-3B-Instruct-GGUF-Q4_K_M.gguf",
708 + );
709 + assert_eq!(name, "Qwen2.5-Coder-3B-Instruct-GGUF");
710 +
711 + // When the file name does NOT contain the repo name, both are shown.
712 + let name = display_name_for_model(
713 + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF",
714 + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf",
715 + );
716 + assert_eq!(
717 + name,
718 + "Qwen2.5-Coder-3B-Instruct-GGUF — Qwen2.5-Coder-3B-Instruct-Q4_K_M"
719 + );
720 + }
721 +
722 + #[test]
723 + fn display_name_includes_file_when_different() {
724 + let name =
725 + display_name_for_model("bartowski/Qwen_Qwen3-4B-GGUF", "Qwen_Qwen3-4B-Q4_K_M.gguf");
726 + assert_eq!(name, "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M");
727 + }
728 +}
src/tools.rs
+20 -12
@@ -198,17 +198,21 @@ pub fn all_tools() -> Vec<AgentTool> {
198 AgentTool {
199 name: "run_command",
200 description: "Run a shell command and return its combined stdout and stderr output. \
201 - The command runs in the given working directory (defaults to \".\"). \
202 - Prefer an absolute working directory when possible. Use this for \
203 - build tools (cargo, npm, make), package managers, linters, test \
204 - runners, and git commands, including git init, porcelain commands \
205 - like status/add/commit/checkout, and plumbing commands like \
206 - rev-parse, hash-object, update-ref, and cat-file. If the user asks \
207 - for a new repo or scaffold, it is fine to use this for `git init` \
208 - and normal repo setup steps. In smbCloud repos, prefer existing \
209 - workspace commands, Rails conventions, and deploy flows over \
210 - inventing new command sequences. Commands that run indefinitely \
211 - (servers, watchers) will be killed after 120 seconds.",
201 + The command runs in the given working directory (defaults to the \
202 + user's home directory). Always use an absolute working directory \
203 + path. Use this for build tools (cargo, npm, make), package managers, \
204 + linters, test runners, and git commands, including git init, \
205 + porcelain commands like status/add/commit/checkout, and plumbing \
206 + commands like rev-parse, hash-object, update-ref, and cat-file. \
207 + For `git clone`, always specify the full absolute destination path \
208 + as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \
209 + and set cwd to the parent directory. Never run `git clone` without \
210 + an explicit destination. If the user asks for a new repo or scaffold, \
211 + use this for `git clone`, `git init`, and normal repo setup steps. \
212 + In smbCloud repos, prefer existing workspace commands, Rails \
213 + conventions, and deploy flows over inventing new command sequences. \
214 + Commands that run indefinitely (servers, watchers) will be killed \
215 + after 120 seconds.",
216 parameters_schema: json!({
217 "type": "object",
218 "properties": {
@@ -705,7 +709,11 @@ fn exec_run_command(arguments: &str) -> String {
709 None => return "Error: missing required parameter \"command\"".to_string(),
710 };
711
708 - let cwd = args.get("cwd").and_then(Value::as_str).unwrap_or(".");
712 + let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
713 + let cwd = args
714 + .get("cwd")
715 + .and_then(Value::as_str)
716 + .unwrap_or(&default_cwd);
717 let cwd_path = absolute_path(Path::new(cwd));
718 let cwd_str = cwd_path.display().to_string();
719