@setoelkahfi / 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
index 703515d..3f9858a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,6 +105,26 @@ smbCloud context you should know and use when it helps: crate boundaries, existing Rails conventions, and existing command flows over \ inventing new abstractions +CRITICAL RULE — never tell the user to run a command. You have tools. Use them. \ +When the user asks you to clone a repo, run a build, check git status, or do \ +anything that involves a shell command, you MUST call the run_command tool and \ +execute it yourself. Do not print shell commands for the user to copy-paste. \ +Do not give step-by-step instructions. Do not say \"you can run …\". Just do it. \ +If a command fails, try to fix the problem and re-run it. If you cannot fix it \ +after two attempts, explain what went wrong and what you tried. + +Git operations — always use run_command: +- git clone: always pass the full absolute destination path as the last argument \ + and set cwd to an existing writable parent directory. Example: \ + run_command({\"command\": \"git clone https://github.com/org/repo /Users/me/Repositories/repo\", \ + \"cwd\": \"/Users/me/Repositories\"}) +- git init, add, commit, push, pull, fetch, checkout, branch, diff, log, status, \ + stash, rebase, merge, tag — use run_command with an absolute cwd pointing to \ + the repo root +- never run git clone without an explicit absolute destination path +- if a clone or init fails, check the error, fix the cause (wrong path, missing \ + directory, permissions), and retry + Never introduce yourself unless asked. Jump straight into the answer. \ Keep answers short. Write idiomatic code. \ Fix root causes, not symptoms. @@ -122,6 +142,9 @@ Tool-use heuristics: - prefer absolute paths over relative paths when you mention, return, or pass \ file and directory paths - if a path does not exist yet, create the directory before creating files in it +- if the user asks to clone a repo, immediately call run_command with git clone \ + and an absolute destination path — do not ask where to put it unless the \ + request is ambiguous; default to the user's home Repositories directory - if the user asks for a new repo, scaffold, or scratch project, create the \ directory, create the first files, and run `git init` without waiting unless \ the request says otherwise @@ -137,6 +160,8 @@ Tool-use heuristics: widen to broader checks if needed - use git commands naturally for status checks, repo setup, diffs, and normal \ developer workflows when they help move the task forward +- if a tool call fails, read the error, try to fix it, and retry — do not \ + fall back to telling the user what to type When the repo is not about smbCloud, act like a normal coding agent and do not \ force smbCloud-specific advice into the answer. When it is about smbCloud, be \
src/setup.rs
+641 -2
index 032d11c..2fce7d8 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,4 +1,5 @@ -//! Shared model cache setup. +//! Shared model cache setup, local model discovery, and lightweight local +//! preferences. //! //! On macOS, siGit desktop and other Onde apps keep their HuggingFace models //! in a shared App Group container at: @@ -9,10 +10,17 @@ //! whatever the desktop app already downloaded (and vice versa). On Linux //! and Windows the default `~/.cache/huggingface/` path is used. //! +//! It also exposes helpers for finding locally available models. Discovery +//! checks the Onde app group first on macOS, then falls back to the normal +//! Hugging Face cache layout. +//! +//! The selected model name is persisted in a small local preferences file so +//! the interactive UI can restore the last choice on the next launch. +//! //! Call this before anything touches `ChatEngine` or `hf-hub` — they read //! the env vars once at init and never check again. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; /// App Group ID shared across all Onde apps (siGit, Rumi, GT8, …). #[cfg(target_os = "macos")] @@ -58,6 +66,356 @@ pub fn setup_shared_model_cache() { } } +/// Preference key used to remember the last selected model. +const SELECTED_MODEL_FILE_NAME: &str = "selected-model.txt"; + +/// Stable persisted identifier for a selected local model. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedModel { + /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`. + pub model_id: String, + /// GGUF filename inside the snapshot. + pub gguf_file: String, +} + +impl SelectedModel { + fn from_discovered(model: &DiscoveredModel) -> Self { + Self { + model_id: model.model_id.clone(), + gguf_file: model.gguf_file.clone(), + } + } + + fn matches(&self, model: &DiscoveredModel) -> bool { + self.model_id == model.model_id && self.gguf_file == model.gguf_file + } +} + +/// Minimal startup model selection info used before the full UI is running. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StartupModelSelection { + /// Human-friendly model name shown in the loading UI. + pub display_name: String, + /// The saved model identifier if one was found. + pub selected_model: Option<SelectedModel>, +} + +/// A locally discovered GGUF model candidate. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredModel { + /// Hugging Face repo ID, e.g. `bartowski/Qwen_Qwen3-4B-GGUF`. + pub model_id: String, + /// GGUF filename inside the snapshot. + pub gguf_file: String, + /// Human-friendly label shown in model pickers. + pub display_name: String, + /// Absolute path to the snapshot directory that contains the GGUF file. + pub snapshot_path: PathBuf, + /// Absolute path to the GGUF file itself. + pub gguf_path: PathBuf, + /// True when the model came from the Onde app group cache. + pub from_app_group: bool, + /// Whether the snapshot looks complete enough to load. + pub cache_health: ModelCacheHealth, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum ModelCacheHealth { + Complete, + Incomplete, +} + +/// Return all locally discovered GGUF models. +/// +/// Search order: +/// 1. Onde app group cache on macOS +/// 2. Standard Hugging Face cache +pub fn discover_local_models() -> Vec<DiscoveredModel> { + let mut models = Vec::new(); + + if let Some(app_group_models) = app_group_models_root() { + collect_models_from_cache_root(&app_group_models, true, &mut models); + } + + if let Some(hf_cache) = hf_cache_root() { + collect_models_from_cache_root(&hf_cache, false, &mut models); + } + + models.sort_by(|left, right| { + left.cache_health + .cmp(&right.cache_health) + .then_with(|| { + left.display_name + .to_lowercase() + .cmp(&right.display_name.to_lowercase()) + }) + .then_with(|| left.model_id.cmp(&right.model_id)) + .then_with(|| left.gguf_file.cmp(&right.gguf_file)) + }); + + models.dedup_by(|left, right| left.gguf_path == right.gguf_path); + models +} + +fn collect_models_from_cache_root( + cache_root: &Path, + from_app_group: bool, + models: &mut Vec<DiscoveredModel>, +) { + let entries = match std::fs::read_dir(cache_root) { + Ok(entries) => entries, + Err(error) => { + log::debug!( + "Skipping unreadable model cache root {}: {error}", + cache_root.display() + ); + return; + } + }; + + for entry in entries.flatten() { + let repo_dir = entry.path(); + if !repo_dir.is_dir() { + continue; + } + + let dir_name = match entry.file_name().to_str() { + Some(name) => name.to_string(), + None => continue, + }; + + if !dir_name.starts_with("models--") { + continue; + } + + let model_id = dir_name["models--".len()..].replace("--", "/"); + let snapshots_dir = repo_dir.join("snapshots"); + let snapshots = match std::fs::read_dir(&snapshots_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for snapshot in snapshots.flatten() { + let snapshot_path = snapshot.path(); + if !snapshot_path.is_dir() { + continue; + } + + let files = match std::fs::read_dir(&snapshot_path) { + Ok(entries) => entries, + Err(_) => continue, + }; + + let mut has_config_json = false; + let mut has_tokenizer = false; + let mut gguf_files = Vec::new(); + + for file in files.flatten() { + let file_path = file.path(); + if !file_path.is_file() { + continue; + } + + let file_name = match file.file_name().to_str() { + Some(name) => name.to_string(), + None => continue, + }; + + if file_name == "config.json" { + has_config_json = true; + } + + if file_name == "tokenizer.json" + || file_name == "tokenizer.model" + || file_name == "tokenizer_config.json" + { + has_tokenizer = true; + } + + let extension = file_path + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or_default(); + + if extension.eq_ignore_ascii_case("gguf") { + gguf_files.push((file_name, file_path)); + } + } + + let cache_health = if has_config_json && has_tokenizer { + ModelCacheHealth::Complete + } else { + ModelCacheHealth::Incomplete + }; + + for (gguf_file, file_path) in gguf_files { + models.push(DiscoveredModel { + display_name: display_name_for_model(&model_id, &gguf_file), + model_id: model_id.clone(), + gguf_file, + snapshot_path: snapshot_path.clone(), + gguf_path: file_path, + from_app_group, + cache_health, + }); + } + } + } +} + +fn display_name_for_model(model_id: &str, gguf_file: &str) -> String { + let repo_name = model_id + .rsplit('/') + .next() + .unwrap_or(model_id) + .replace('_', " "); + + let file_name = gguf_file.strip_suffix(".gguf").unwrap_or(gguf_file); + + if file_name.contains(&repo_name.replace(' ', "_")) || file_name.contains(&repo_name) { + repo_name + } else { + format!("{repo_name} — {file_name}") + } +} + +fn app_group_models_root() -> Option<PathBuf> { + resolve_shared_container().map(|dir| dir.join("models").join("hub")) +} + +fn hf_cache_root() -> Option<PathBuf> { + if let Ok(cache) = std::env::var("HF_HUB_CACHE") { + let path = PathBuf::from(cache); + if path.is_dir() { + return Some(path); + } + } + + if let Ok(home) = std::env::var("HF_HOME") { + let path = PathBuf::from(home).join("hub"); + if path.is_dir() { + return Some(path); + } + } + + let home = std::env::var("HOME").ok()?; + let path = PathBuf::from(home) + .join(".cache") + .join("huggingface") + .join("hub"); + + path.is_dir().then_some(path) +} + +pub fn load_selected_model() -> Option<SelectedModel> { + let path = selected_model_file_path()?; + let contents = std::fs::read_to_string(path).ok()?; + let trimmed = contents.trim(); + if trimmed.is_empty() { + return None; + } + + let mut parts = trimmed.splitn(2, '\n'); + let model_id = parts.next()?.trim(); + let gguf_file = parts.next()?.trim(); + + if model_id.is_empty() || gguf_file.is_empty() { + return None; + } + + Some(SelectedModel { + model_id: model_id.to_string(), + gguf_file: gguf_file.to_string(), + }) +} + +#[allow(dead_code)] +pub fn load_selected_model_name() -> Option<String> { + let selected = load_selected_model()?; + discover_local_models() + .into_iter() + .find(|model| selected.matches(model)) + .map(|model| model.display_name) +} + +/// Pick the model name siGit should try to load at startup. +/// +/// Order: +/// 1. saved selection, if it still exists locally +/// 2. first discovered local model (Onde app group first, then HF cache) +/// 3. no selection +/// +/// If there is no saved selection but a local model is discovered, persist that +/// fallback choice so ACP mode and the interactive TUI converge on the same +/// startup model on the next launch too. +pub fn startup_model_selection() -> Option<StartupModelSelection> { + let discovered = discover_local_models(); + + if let Some(saved_model) = load_selected_model() + && let Some(model) = discovered.iter().find(|model| { + saved_model.matches(model) && model.cache_health == ModelCacheHealth::Complete + }) + { + return Some(StartupModelSelection { + display_name: model.display_name.clone(), + selected_model: Some(saved_model), + }); + } + + discovered + .into_iter() + .find(|model| model.cache_health == ModelCacheHealth::Complete) + .map(|model| { + let selected_model = SelectedModel::from_discovered(&model); + let _ = save_selected_model(&selected_model); + StartupModelSelection { + display_name: model.display_name.clone(), + selected_model: Some(selected_model), + } + }) +} + +pub fn save_selected_model(selected_model: &SelectedModel) -> Result<(), String> { + let path = selected_model_file_path() + .ok_or_else(|| "Could not determine where to store the selected model.".to_string())?; + + if let Some(parent) = path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent) + .map_err(|error| format!("Could not create preferences directory: {error}"))?; + } + + let contents = format!( + "{}\n{}\n", + selected_model.model_id, selected_model.gguf_file + ); + + std::fs::write(&path, contents) + .map_err(|error| format!("Could not save selected model: {error}")) +} + +fn selected_model_file_path() -> Option<PathBuf> { + if let Some(shared_dir) = resolve_shared_container() { + return Some(shared_dir.join(SELECTED_MODEL_FILE_NAME)); + } + + if let Ok(home) = std::env::var("HF_HOME") { + let path = PathBuf::from(home); + if path.is_dir() || path.parent().is_some() { + return Some(path.join(SELECTED_MODEL_FILE_NAME)); + } + } + + let home = std::env::var("HOME").ok()?; + Some( + PathBuf::from(home) + .join(".cache") + .join("sigit") + .join(SELECTED_MODEL_FILE_NAME), + ) +} + /// Look for the App Group container on disk. macOS creates it the first time /// a signed app in the group accesses it, so it only exists if the user has /// launched siGit desktop (or another Onde app) at least once. A plain CLI @@ -87,3 +445,284 @@ fn resolve_shared_container() -> Option<PathBuf> { fn resolve_shared_container() -> Option<PathBuf> { None } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Mutex, OnceLock}; + + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn unique_temp_dir(name: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos(); + + std::env::temp_dir().join(format!("sigit-setup-tests-{name}-{nanos}")) + } + + fn create_snapshot( + cache_root: &Path, + model_id: &str, + snapshot_name: &str, + gguf_file: &str, + complete: bool, + ) -> PathBuf { + let repo_dir = cache_root.join(format!("models--{}", model_id.replace('/', "--"))); + let snapshot_dir = repo_dir.join("snapshots").join(snapshot_name); + std::fs::create_dir_all(&snapshot_dir).expect("create snapshot dir"); + std::fs::write(snapshot_dir.join(gguf_file), b"gguf").expect("write gguf"); + + if complete { + std::fs::write(snapshot_dir.join("config.json"), b"{}").expect("write config"); + std::fs::write(snapshot_dir.join("tokenizer.json"), b"{}").expect("write tokenizer"); + } + + snapshot_dir + } + + fn with_test_env<T>(hf_hub_cache: &Path, hf_home: &Path, f: impl FnOnce() -> T) -> T { + let _guard = env_lock().lock().expect("lock env"); + + let old_hf_hub_cache = std::env::var_os("HF_HUB_CACHE"); + let old_hf_home = std::env::var_os("HF_HOME"); + let old_home = std::env::var_os("HOME"); + + // SAFETY: tests serialize environment mutation with a process-wide mutex. + unsafe { + std::env::set_var("HF_HUB_CACHE", hf_hub_cache); + std::env::set_var("HF_HOME", hf_home); + std::env::set_var("HOME", hf_home); + } + + let result = f(); + + // SAFETY: tests serialize environment mutation with a process-wide mutex. + unsafe { + match old_hf_hub_cache { + Some(value) => std::env::set_var("HF_HUB_CACHE", value), + None => std::env::remove_var("HF_HUB_CACHE"), + } + match old_hf_home { + Some(value) => std::env::set_var("HF_HOME", value), + None => std::env::remove_var("HF_HOME"), + } + match old_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + } + + result + } + + #[test] + fn discover_local_models_marks_complete_and_incomplete_snapshots() { + let root = unique_temp_dir("discover-health"); + let cache_root = root.join("hf-cache"); + let hf_home = root.join("hf-home"); + std::fs::create_dir_all(&cache_root).expect("create cache root"); + std::fs::create_dir_all(&hf_home).expect("create hf home"); + + create_snapshot( + &cache_root, + "bartowski/Qwen_Qwen3-4B-GGUF", + "complete", + "Qwen_Qwen3-4B-Q4_K_M.gguf", + true, + ); + create_snapshot( + &cache_root, + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", + "incomplete", + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", + false, + ); + + let models = with_test_env(&cache_root, &hf_home, discover_local_models); + + assert_eq!(models.len(), 2); + + let complete = models + .iter() + .find(|model| model.model_id == "bartowski/Qwen_Qwen3-4B-GGUF") + .expect("complete model discovered"); + assert_eq!(complete.cache_health, ModelCacheHealth::Complete); + assert!(!complete.from_app_group); + + let incomplete = models + .iter() + .find(|model| model.model_id == "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF") + .expect("incomplete model discovered"); + assert_eq!(incomplete.cache_health, ModelCacheHealth::Incomplete); + assert!(!incomplete.from_app_group); + + std::fs::remove_dir_all(root).expect("remove temp dir"); + } + + #[test] + fn startup_model_selection_skips_saved_incomplete_model_and_picks_complete_one() { + let root = unique_temp_dir("startup-selection"); + let cache_root = root.join("hf-cache"); + let hf_home = root.join("hf-home"); + std::fs::create_dir_all(&cache_root).expect("create cache root"); + std::fs::create_dir_all(&hf_home).expect("create hf home"); + + create_snapshot( + &cache_root, + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", + "broken", + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", + false, + ); + create_snapshot( + &cache_root, + "bartowski/Qwen_Qwen3-4B-GGUF", + "ready", + "Qwen_Qwen3-4B-Q4_K_M.gguf", + true, + ); + + let selection = with_test_env(&cache_root, &hf_home, || { + let selected_path = selected_model_file_path().expect("selected model path"); + if let Some(parent) = selected_path.parent() { + std::fs::create_dir_all(parent).expect("create selected model parent"); + } + + std::fs::write( + &selected_path, + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF\nQwen2.5-Coder-3B-Instruct-Q4_K_M.gguf\n", + ) + .expect("write selected model"); + + startup_model_selection().expect("startup selection") + }); + + assert_eq!( + selection.display_name, + "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M" + ); + let selected = selection.selected_model.expect("selected model"); + assert_eq!(selected.model_id, "bartowski/Qwen_Qwen3-4B-GGUF"); + assert_eq!(selected.gguf_file, "Qwen_Qwen3-4B-Q4_K_M.gguf"); + + std::fs::remove_dir_all(root).expect("remove temp dir"); + } + + #[test] + fn discover_empty_cache_returns_no_models() { + let root = unique_temp_dir("discover-empty"); + let cache_root = root.join("hf-cache"); + let hf_home = root.join("hf-home"); + std::fs::create_dir_all(&cache_root).expect("create cache root"); + std::fs::create_dir_all(&hf_home).expect("create hf home"); + + let models = with_test_env(&cache_root, &hf_home, discover_local_models); + assert!(models.is_empty()); + + std::fs::remove_dir_all(root).expect("remove temp dir"); + } + + #[test] + fn complete_models_sort_before_incomplete() { + let root = unique_temp_dir("sort-order"); + let cache_root = root.join("hf-cache"); + let hf_home = root.join("hf-home"); + std::fs::create_dir_all(&cache_root).expect("create cache root"); + std::fs::create_dir_all(&hf_home).expect("create hf home"); + + create_snapshot( + &cache_root, + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", + "snap1", + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", + false, + ); + create_snapshot( + &cache_root, + "bartowski/Qwen_Qwen3-4B-GGUF", + "snap2", + "Qwen_Qwen3-4B-Q4_K_M.gguf", + true, + ); + + let models = with_test_env(&cache_root, &hf_home, discover_local_models); + assert_eq!(models.len(), 2); + assert_eq!(models[0].cache_health, ModelCacheHealth::Complete); + assert_eq!(models[1].cache_health, ModelCacheHealth::Incomplete); + + std::fs::remove_dir_all(root).expect("remove temp dir"); + } + + #[test] + fn load_selected_model_roundtrip() { + let root = unique_temp_dir("persistence-roundtrip"); + let hf_home = root.join("hf-home"); + std::fs::create_dir_all(&hf_home).expect("create hf home"); + + with_test_env(&hf_home, &hf_home, || { + let original = SelectedModel { + model_id: "bartowski/Qwen_Qwen3-4B-GGUF".to_string(), + gguf_file: "Qwen_Qwen3-4B-Q4_K_M.gguf".to_string(), + }; + + save_selected_model(&original).expect("save"); + let loaded = load_selected_model().expect("load"); + + assert_eq!(loaded.model_id, original.model_id); + assert_eq!(loaded.gguf_file, original.gguf_file); + }); + + std::fs::remove_dir_all(root).expect("remove temp dir"); + } + + #[test] + fn load_selected_model_empty_file_returns_none() { + let root = unique_temp_dir("persistence-empty"); + let hf_home = root.join("hf-home"); + std::fs::create_dir_all(&hf_home).expect("create hf home"); + + with_test_env(&hf_home, &hf_home, || { + let path = selected_model_file_path().expect("path"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(&path, "").expect("write empty"); + + assert!(load_selected_model().is_none()); + }); + + std::fs::remove_dir_all(root).expect("remove temp dir"); + } + + #[test] + fn display_name_deduplicates_repo_and_file_name() { + // When the file name contains the repo name, only the repo name is shown. + let name = display_name_for_model( + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", + "Qwen2.5-Coder-3B-Instruct-GGUF-Q4_K_M.gguf", + ); + assert_eq!(name, "Qwen2.5-Coder-3B-Instruct-GGUF"); + + // When the file name does NOT contain the repo name, both are shown. + let name = display_name_for_model( + "bartowski/Qwen2.5-Coder-3B-Instruct-GGUF", + "Qwen2.5-Coder-3B-Instruct-Q4_K_M.gguf", + ); + assert_eq!( + name, + "Qwen2.5-Coder-3B-Instruct-GGUF — Qwen2.5-Coder-3B-Instruct-Q4_K_M" + ); + } + + #[test] + fn display_name_includes_file_when_different() { + let name = + display_name_for_model("bartowski/Qwen_Qwen3-4B-GGUF", "Qwen_Qwen3-4B-Q4_K_M.gguf"); + assert_eq!(name, "Qwen Qwen3-4B-GGUF — Qwen_Qwen3-4B-Q4_K_M"); + } +}
src/tools.rs
+20 -12
index 9e1d123..a0bfbe0 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -198,17 +198,21 @@ pub fn all_tools() -> Vec<AgentTool> { AgentTool { name: "run_command", description: "Run a shell command and return its combined stdout and stderr output. \ - The command runs in the given working directory (defaults to \".\"). \ - Prefer an absolute working directory when possible. Use this for \ - build tools (cargo, npm, make), package managers, linters, test \ - runners, and git commands, including git init, porcelain commands \ - like status/add/commit/checkout, and plumbing commands like \ - rev-parse, hash-object, update-ref, and cat-file. If the user asks \ - for a new repo or scaffold, it is fine to use this for `git init` \ - and normal repo setup steps. In smbCloud repos, prefer existing \ - workspace commands, Rails conventions, and deploy flows over \ - inventing new command sequences. Commands that run indefinitely \ - (servers, watchers) will be killed after 120 seconds.", + The command runs in the given working directory (defaults to the \ + user's home directory). Always use an absolute working directory \ + path. Use this for build tools (cargo, npm, make), package managers, \ + linters, test runners, and git commands, including git init, \ + porcelain commands like status/add/commit/checkout, and plumbing \ + commands like rev-parse, hash-object, update-ref, and cat-file. \ + For `git clone`, always specify the full absolute destination path \ + as the last argument (e.g. `git clone <url> /absolute/path/to/dir`) \ + and set cwd to the parent directory. Never run `git clone` without \ + an explicit destination. If the user asks for a new repo or scaffold, \ + use this for `git clone`, `git init`, and normal repo setup steps. \ + In smbCloud repos, prefer existing workspace commands, Rails \ + conventions, and deploy flows over inventing new command sequences. \ + Commands that run indefinitely (servers, watchers) will be killed \ + after 120 seconds.", parameters_schema: json!({ "type": "object", "properties": { @@ -705,7 +709,11 @@ fn exec_run_command(arguments: &str) -> String { None => return "Error: missing required parameter \"command\"".to_string(), }; - let cwd = args.get("cwd").and_then(Value::as_str).unwrap_or("."); + let default_cwd = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + let cwd = args + .get("cwd") + .and_then(Value::as_str) + .unwrap_or(&default_cwd); let cwd_path = absolute_path(Path::new(cwd)); let cwd_str = cwd_path.display().to_string();