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