diff --git a/src/doctor/mod.rs b/src/doctor/mod.rs index c8a034707..b39c5105e 100644 --- a/src/doctor/mod.rs +++ b/src/doctor/mod.rs @@ -147,7 +147,11 @@ fn doctor_model_targets(provider_override: Option<&str>) -> Vec { .collect() } -pub fn run_models(config: &Config, provider_override: Option<&str>, use_cache: bool) -> Result<()> { +pub async fn run_models( + config: &Config, + provider_override: Option<&str>, + use_cache: bool, +) -> Result<()> { let targets = doctor_model_targets(provider_override); if targets.is_empty() { @@ -174,7 +178,7 @@ pub fn run_models(config: &Config, provider_override: Option<&str>, use_cache: b for provider_name in &targets { println!(" [{}]", provider_name); - match crate::onboard::run_models_refresh(config, Some(provider_name), !use_cache) { + match crate::onboard::run_models_refresh(config, Some(provider_name), !use_cache).await { Ok(()) => { ok_count += 1; println!(" ✅ model catalog check passed"); diff --git a/src/main.rs b/src/main.rs index d83e734d7..9e9bc16a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -781,12 +781,7 @@ async fn main() -> Result<()> { Commands::Models { model_command } => match model_command { ModelCommands::Refresh { provider, force } => { - let config_for_refresh = config.clone(); - tokio::task::spawn_blocking(move || { - onboard::run_models_refresh(&config_for_refresh, provider.as_deref(), force) - }) - .await - .map_err(|e| anyhow::anyhow!("models refresh task failed: {e}"))? + onboard::run_models_refresh(&config, provider.as_deref(), force).await } }, @@ -835,14 +830,7 @@ async fn main() -> Result<()> { Some(DoctorCommands::Models { provider, use_cache, - }) => { - let config_for_models = config.clone(); - tokio::task::spawn_blocking(move || { - doctor::run_models(&config_for_models, provider.as_deref(), use_cache) - }) - .await - .map_err(|e| anyhow::anyhow!("doctor models task failed: {e}"))? - } + }) => doctor::run_models(&config, provider.as_deref(), use_cache).await, None => doctor::run(&config), }, diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index d622ae7ff..d7702e65b 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -21,10 +21,10 @@ use dialoguer::{Confirm, Input, Select}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeSet; -use std::fs; use std::io::IsTerminal; use std::path::{Path, PathBuf}; use std::time::Duration; +use tokio::fs; // ── Project context collected during wizard ────────────────────── @@ -115,11 +115,11 @@ pub async fn run_wizard(force: bool) -> Result { println!(); print_step(1, 9, "Workspace Setup"); - let (workspace_dir, config_path) = setup_workspace()?; + let (workspace_dir, config_path) = setup_workspace().await?; ensure_onboard_overwrite_allowed(&config_path, force)?; print_step(2, 9, "AI Provider & API Key"); - let (provider, api_key, model, provider_api_url) = setup_provider(&workspace_dir)?; + let (provider, api_key, model, provider_api_url) = setup_provider(&workspace_dir).await?; print_step(3, 9, "Channels (How You Talk to ZeroClaw)"); let channels_config = setup_channels()?; @@ -140,7 +140,7 @@ pub async fn run_wizard(force: bool) -> Result { let project_ctx = setup_project_context()?; print_step(9, 9, "Workspace Files"); - scaffold_workspace(&workspace_dir, &project_ctx)?; + scaffold_workspace(&workspace_dir, &project_ctx).await?; // ── Build config ── // Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime @@ -375,7 +375,9 @@ async fn run_quick_setup_with_home( let config_path = zeroclaw_dir.join("config.toml"); ensure_onboard_overwrite_allowed(&config_path, force)?; - fs::create_dir_all(&workspace_dir).context("Failed to create workspace directory")?; + fs::create_dir_all(&workspace_dir) + .await + .context("Failed to create workspace directory")?; let provider_name = provider.unwrap_or("openrouter").to_string(); let model = model_override @@ -444,7 +446,7 @@ async fn run_quick_setup_with_home( "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing." .into(), }; - scaffold_workspace(&workspace_dir, &default_ctx)?; + scaffold_workspace(&workspace_dir, &default_ctx).await?; println!( " {} Workspace: {}", @@ -1393,13 +1395,14 @@ fn now_unix_secs() -> u64 { .map_or(0, |duration| duration.as_secs()) } -fn load_model_cache_state(workspace_dir: &Path) -> Result { +async fn load_model_cache_state(workspace_dir: &Path) -> Result { let path = model_cache_path(workspace_dir); if !path.exists() { return Ok(ModelCacheState::default()); } let raw = fs::read_to_string(&path) + .await .with_context(|| format!("failed to read model cache at {}", path.display()))?; match serde_json::from_str::(&raw) { @@ -1408,10 +1411,10 @@ fn load_model_cache_state(workspace_dir: &Path) -> Result { } } -fn save_model_cache_state(workspace_dir: &Path, state: &ModelCacheState) -> Result<()> { +async fn save_model_cache_state(workspace_dir: &Path, state: &ModelCacheState) -> Result<()> { let path = model_cache_path(workspace_dir); if let Some(parent) = path.parent() { - fs::create_dir_all(parent).with_context(|| { + fs::create_dir_all(parent).await.with_context(|| { format!( "failed to create model cache directory {}", parent.display() @@ -1421,12 +1424,13 @@ fn save_model_cache_state(workspace_dir: &Path, state: &ModelCacheState) -> Resu let json = serde_json::to_vec_pretty(state).context("failed to serialize model cache")?; fs::write(&path, json) + .await .with_context(|| format!("failed to write model cache at {}", path.display()))?; Ok(()) } -fn cache_live_models_for_provider( +async fn cache_live_models_for_provider( workspace_dir: &Path, provider_name: &str, models: &[String], @@ -1436,7 +1440,7 @@ fn cache_live_models_for_provider( return Ok(()); } - let mut state = load_model_cache_state(workspace_dir)?; + let mut state = load_model_cache_state(workspace_dir).await?; let now = now_unix_secs(); if let Some(entry) = state @@ -1454,15 +1458,15 @@ fn cache_live_models_for_provider( }); } - save_model_cache_state(workspace_dir, &state) + save_model_cache_state(workspace_dir, &state).await } -fn load_cached_models_for_provider_internal( +async fn load_cached_models_for_provider_internal( workspace_dir: &Path, provider_name: &str, ttl_secs: Option, ) -> Result> { - let state = load_model_cache_state(workspace_dir)?; + let state = load_model_cache_state(workspace_dir).await?; let now = now_unix_secs(); let Some(entry) = state @@ -1488,19 +1492,19 @@ fn load_cached_models_for_provider_internal( })) } -fn load_cached_models_for_provider( +async fn load_cached_models_for_provider( workspace_dir: &Path, provider_name: &str, ttl_secs: u64, ) -> Result> { - load_cached_models_for_provider_internal(workspace_dir, provider_name, Some(ttl_secs)) + load_cached_models_for_provider_internal(workspace_dir, provider_name, Some(ttl_secs)).await } -fn load_any_cached_models_for_provider( +async fn load_any_cached_models_for_provider( workspace_dir: &Path, provider_name: &str, ) -> Result> { - load_cached_models_for_provider_internal(workspace_dir, provider_name, None) + load_cached_models_for_provider_internal(workspace_dir, provider_name, None).await } fn humanize_age(age_secs: u64) -> String { @@ -1537,7 +1541,7 @@ fn print_model_preview(models: &[String]) { } } -pub fn run_models_refresh( +pub async fn run_models_refresh( config: &Config, provider_override: Option<&str>, force: bool, @@ -1561,7 +1565,9 @@ pub fn run_models_refresh( &config.workspace_dir, &provider_name, MODEL_CACHE_TTL_SECS, - )? { + ) + .await? + { println!( "Using cached model list for '{}' (updated {} ago):", provider_name, @@ -1581,7 +1587,7 @@ pub fn run_models_refresh( match fetch_live_models_for_provider(&provider_name, &api_key, config.api_url.as_deref()) { Ok(models) if !models.is_empty() => { - cache_live_models_for_provider(&config.workspace_dir, &provider_name, &models)?; + cache_live_models_for_provider(&config.workspace_dir, &provider_name, &models).await?; println!( "Refreshed '{}' model cache with {} models.", provider_name, @@ -1592,7 +1598,7 @@ pub fn run_models_refresh( } Ok(_) => { if let Some(stale_cache) = - load_any_cached_models_for_provider(&config.workspace_dir, &provider_name)? + load_any_cached_models_for_provider(&config.workspace_dir, &provider_name).await? { println!( "Provider returned no models; using stale cache (updated {} ago):", @@ -1606,7 +1612,7 @@ pub fn run_models_refresh( } Err(error) => { if let Some(stale_cache) = - load_any_cached_models_for_provider(&config.workspace_dir, &provider_name)? + load_any_cached_models_for_provider(&config.workspace_dir, &provider_name).await? { println!( "Live refresh failed ({}). Falling back to stale cache (updated {} ago):", @@ -1691,7 +1697,7 @@ async fn persist_workspace_selection(config_path: &Path) -> Result<()> { // ── Step 1: Workspace ──────────────────────────────────────────── -fn setup_workspace() -> Result<(PathBuf, PathBuf)> { +async fn setup_workspace() -> Result<(PathBuf, PathBuf)> { let home = directories::UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; @@ -1720,7 +1726,9 @@ fn setup_workspace() -> Result<(PathBuf, PathBuf)> { let workspace_dir = zeroclaw_dir.join("workspace"); let config_path = zeroclaw_dir.join("config.toml"); - fs::create_dir_all(&workspace_dir).context("Failed to create workspace directory")?; + fs::create_dir_all(&workspace_dir) + .await + .context("Failed to create workspace directory")?; println!( " {} Workspace: {}", @@ -1734,7 +1742,7 @@ fn setup_workspace() -> Result<(PathBuf, PathBuf)> { // ── Step 2: Provider & API Key ─────────────────────────────────── #[allow(clippy::too_many_lines)] -fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Option)> { +async fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Option)> { // ── Tier selection ── let tiers = vec![ "⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)", @@ -2235,7 +2243,8 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio if can_fetch_without_key || has_api_key { if let Some(cached) = - load_cached_models_for_provider(workspace_dir, provider_name, MODEL_CACHE_TTL_SECS)? + load_cached_models_for_provider(workspace_dir, provider_name, MODEL_CACHE_TTL_SECS) + .await? { let shown_count = cached.models.len().min(LIVE_MODEL_MAX_OPTIONS); print_bullet(&format!( @@ -2273,7 +2282,8 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio workspace_dir, provider_name, &live_model_ids, - )?; + ) + .await?; let fetched_count = live_model_ids.len(); let shown_count = fetched_count.min(LIVE_MODEL_MAX_OPTIONS); @@ -2303,7 +2313,8 @@ fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String, Optio if live_options.is_none() { if let Some(stale) = - load_any_cached_models_for_provider(workspace_dir, provider_name)? + load_any_cached_models_for_provider(workspace_dir, provider_name) + .await? { print_bullet(&format!( "Loaded stale cache from {} ago.", @@ -4477,7 +4488,7 @@ fn setup_tunnel() -> Result { // ── Step 6: Scaffold workspace files ───────────────────────────── #[allow(clippy::too_many_lines)] -fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Result<()> { +async fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Result<()> { let agent = if ctx.agent_name.is_empty() { "ZeroClaw" } else { @@ -4706,7 +4717,7 @@ fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Result<()> // Create subdirectories let subdirs = ["sessions", "memory", "state", "cron", "skills"]; for dir in &subdirs { - fs::create_dir_all(workspace_dir.join(dir))?; + fs::create_dir_all(workspace_dir.join(dir)).await?; } let mut created = 0; @@ -4717,7 +4728,7 @@ fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Result<()> if path.exists() { skipped += 1; } else { - fs::write(&path, content)?; + fs::write(&path, content).await?; created += 1; } } @@ -5133,11 +5144,11 @@ mod tests { // ── scaffold_workspace: basic file creation ───────────────── - #[test] - fn scaffold_creates_all_md_files() { + #[tokio::test] + async fn scaffold_creates_all_md_files() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let expected = [ "IDENTITY.md", @@ -5154,11 +5165,11 @@ mod tests { } } - #[test] - fn scaffold_creates_all_subdirectories() { + #[tokio::test] + async fn scaffold_creates_all_subdirectories() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); for dir in &["sessions", "memory", "state", "cron", "skills"] { assert!(tmp.path().join(dir).is_dir(), "missing subdirectory: {dir}"); @@ -5174,7 +5185,7 @@ mod tests { user_name: "Alice".into(), ..Default::default() }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md")) .await @@ -5200,7 +5211,7 @@ mod tests { timezone: "US/Pacific".into(), ..Default::default() }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md")) .await @@ -5226,7 +5237,7 @@ mod tests { agent_name: "Crabby".into(), ..Default::default() }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let identity = tokio::fs::read_to_string(tmp.path().join("IDENTITY.md")) .await @@ -5276,7 +5287,7 @@ mod tests { communication_style: "Be technical and detailed.".into(), ..Default::default() }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md")) .await @@ -5309,7 +5320,7 @@ mod tests { async fn scaffold_uses_defaults_for_empty_context() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); // all empty - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let identity = tokio::fs::read_to_string(tmp.path().join("IDENTITY.md")) .await @@ -5352,9 +5363,11 @@ mod tests { // Pre-create SOUL.md with custom content let soul_path = tmp.path().join("SOUL.md"); - fs::write(&soul_path, "# My Custom Soul\nDo not overwrite me.").unwrap(); + fs::write(&soul_path, "# My Custom Soul\nDo not overwrite me.") + .await + .unwrap(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); // SOUL.md should be untouched let soul = tokio::fs::read_to_string(&soul_path).await.unwrap(); @@ -5385,13 +5398,13 @@ mod tests { ..Default::default() }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let soul_v1 = tokio::fs::read_to_string(tmp.path().join("SOUL.md")) .await .unwrap(); // Run again — should not change anything - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let soul_v2 = tokio::fs::read_to_string(tmp.path().join("SOUL.md")) .await .unwrap(); @@ -5405,7 +5418,7 @@ mod tests { async fn scaffold_files_are_non_empty() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); for f in &[ "IDENTITY.md", @@ -5428,7 +5441,7 @@ mod tests { async fn agents_md_references_on_demand_memory() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let agents = tokio::fs::read_to_string(tmp.path().join("AGENTS.md")) .await @@ -5449,7 +5462,7 @@ mod tests { async fn memory_md_warns_about_token_cost() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let memory = tokio::fs::read_to_string(tmp.path().join("MEMORY.md")) .await @@ -5470,7 +5483,7 @@ mod tests { async fn tools_md_lists_all_builtin_tools() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let tools = tokio::fs::read_to_string(tmp.path().join("TOOLS.md")) .await @@ -5502,7 +5515,7 @@ mod tests { async fn soul_md_includes_emoji_awareness_guidance() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let soul = tokio::fs::read_to_string(tmp.path().join("SOUL.md")) .await @@ -5528,7 +5541,7 @@ mod tests { timezone: "Europe/Madrid".into(), communication_style: "Be direct.".into(), }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); let user_md = tokio::fs::read_to_string(tmp.path().join("USER.md")) .await @@ -5554,7 +5567,7 @@ mod tests { "Be friendly, human, and conversational. Show warmth and empathy while staying efficient. Use natural contractions." .into(), }; - scaffold_workspace(tmp.path(), &ctx).unwrap(); + scaffold_workspace(tmp.path(), &ctx).await.unwrap(); // Verify every file got personalized let identity = tokio::fs::read_to_string(tmp.path().join("IDENTITY.md")) @@ -6049,15 +6062,18 @@ mod tests { ); } - #[test] - fn model_cache_round_trip_returns_fresh_entry() { + #[tokio::test] + async fn model_cache_round_trip_returns_fresh_entry() { let tmp = TempDir::new().unwrap(); let models = vec!["gpt-5.1".to_string(), "gpt-5-mini".to_string()]; - cache_live_models_for_provider(tmp.path(), "openai", &models).unwrap(); + cache_live_models_for_provider(tmp.path(), "openai", &models) + .await + .unwrap(); - let cached = - load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS).unwrap(); + let cached = load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS) + .await + .unwrap(); let cached = cached.expect("expected fresh cached models"); assert_eq!(cached.models.len(), 2); @@ -6065,8 +6081,8 @@ mod tests { assert!(cached.models.contains(&"gpt-5-mini".to_string())); } - #[test] - fn model_cache_ttl_filters_stale_entries() { + #[tokio::test] + async fn model_cache_ttl_filters_stale_entries() { let tmp = TempDir::new().unwrap(); let stale = ModelCacheState { entries: vec![ModelCacheEntry { @@ -6076,21 +6092,26 @@ mod tests { }], }; - save_model_cache_state(tmp.path(), &stale).unwrap(); + save_model_cache_state(tmp.path(), &stale).await.unwrap(); - let fresh = - load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS).unwrap(); + let fresh = load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS) + .await + .unwrap(); assert!(fresh.is_none()); - let stale_any = load_any_cached_models_for_provider(tmp.path(), "openai").unwrap(); + let stale_any = load_any_cached_models_for_provider(tmp.path(), "openai") + .await + .unwrap(); assert!(stale_any.is_some()); } - #[test] - fn run_models_refresh_uses_fresh_cache_without_network() { + #[tokio::test] + async fn run_models_refresh_uses_fresh_cache_without_network() { let tmp = TempDir::new().unwrap(); - cache_live_models_for_provider(tmp.path(), "openai", &["gpt-5.1".to_string()]).unwrap(); + cache_live_models_for_provider(tmp.path(), "openai", &["gpt-5.1".to_string()]) + .await + .unwrap(); let config = Config { workspace_dir: tmp.path().to_path_buf(), @@ -6098,11 +6119,11 @@ mod tests { ..Config::default() }; - run_models_refresh(&config, None, false).unwrap(); + run_models_refresh(&config, None, false).await.unwrap(); } - #[test] - fn run_models_refresh_rejects_unsupported_provider() { + #[tokio::test] + async fn run_models_refresh_rejects_unsupported_provider() { let tmp = TempDir::new().unwrap(); let config = Config { @@ -6112,7 +6133,7 @@ mod tests { ..Config::default() }; - let err = run_models_refresh(&config, None, true).unwrap_err(); + let err = run_models_refresh(&config, None, true).await.unwrap_err(); assert!(err .to_string() .contains("does not support live model discovery"));