use crate::config::schema::{DingTalkConfig, IrcConfig, WhatsAppConfig}; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, TelegramConfig, WebhookConfig, }; use crate::hardware::{self, HardwareConfig}; use crate::memory::{ default_memory_backend_key, memory_backend_profile, selectable_memory_backends, }; use anyhow::{bail, Context, Result}; use console::style; use dialoguer::{Confirm, Input, Select}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::Duration; // ── Project context collected during wizard ────────────────────── /// User-provided personalization baked into workspace MD files. #[derive(Debug, Clone, Default)] pub struct ProjectContext { pub user_name: String, pub timezone: String, pub agent_name: String, pub communication_style: String, } // ── Banner ─────────────────────────────────────────────────────── const BANNER: &str = r" ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ ███████╗███████╗██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗ ╚══███╔╝██╔════╝██╔══██╗██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║ ███╔╝ █████╗ ██████╔╝██║ ██║██║ ██║ ███████║██║ █╗ ██║ ███╔╝ ██╔══╝ ██╔══██╗██║ ██║██║ ██║ ██╔══██║██║███╗██║ ███████╗███████╗██║ ██║╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝ ╚══════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝ Zero overhead. Zero compromise. 100% Rust. 100% Agnostic. ⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡⚡ "; const LIVE_MODEL_MAX_OPTIONS: usize = 120; const MODEL_PREVIEW_LIMIT: usize = 20; const MODEL_CACHE_FILE: &str = "models_cache.json"; const MODEL_CACHE_TTL_SECS: u64 = 12 * 60 * 60; const CUSTOM_MODEL_SENTINEL: &str = "__custom_model__"; // ── Main wizard entry point ────────────────────────────────────── pub fn run_wizard() -> Result { println!("{}", style(BANNER).cyan().bold()); println!( " {}", style("Welcome to ZeroClaw — the fastest, smallest AI assistant.") .white() .bold() ); println!( " {}", style("This wizard will configure your agent in under 60 seconds.").dim() ); println!(); print_step(1, 9, "Workspace Setup"); let (workspace_dir, config_path) = setup_workspace()?; print_step(2, 9, "AI Provider & API Key"); let (provider, api_key, model) = setup_provider(&workspace_dir)?; print_step(3, 9, "Channels (How You Talk to ZeroClaw)"); let channels_config = setup_channels()?; print_step(4, 9, "Tunnel (Expose to Internet)"); let tunnel_config = setup_tunnel()?; print_step(5, 9, "Tool Mode & Security"); let (composio_config, secrets_config) = setup_tool_mode()?; print_step(6, 9, "Hardware (Physical World)"); let hardware_config = setup_hardware()?; print_step(7, 9, "Memory Configuration"); let memory_config = setup_memory()?; print_step(8, 9, "Project Context (Personalize Your Agent)"); let project_ctx = setup_project_context()?; print_step(9, 9, "Workspace Files"); scaffold_workspace(&workspace_dir, &project_ctx)?; // ── Build config ── // Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime let config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), api_key: if api_key.is_empty() { None } else { Some(api_key) }, api_url: None, default_provider: Some(provider), default_model: Some(model), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), agent: crate::config::schema::AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), cron: crate::config::CronConfig::default(), channels_config, memory: memory_config, // User-selected memory backend tunnel: tunnel_config, gateway: crate::config::GatewayConfig::default(), composio: composio_config, secrets: secrets_config, browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), hardware: hardware_config, }; println!( " {} Security: {} | workspace-scoped", style("✓").green().bold(), style("Supervised").green() ); println!( " {} Memory: {} (auto-save: {})", style("✓").green().bold(), style(&config.memory.backend).green(), if config.memory.auto_save { "on" } else { "off" } ); config.save()?; // ── Final summary ──────────────────────────────────────────── print_summary(&config); // ── Offer to launch channels immediately ───────────────────── let has_channels = config.channels_config.telegram.is_some() || config.channels_config.discord.is_some() || config.channels_config.slack.is_some() || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() || config.channels_config.dingtalk.is_some(); if has_channels && config.api_key.is_some() { let launch: bool = Confirm::new() .with_prompt(format!( " {} Launch channels now? (connected channels → AI → reply)", style("🚀").cyan() )) .default(true) .interact()?; if launch { println!(); println!( " {} {}", style("⚡").cyan(), style("Starting channel server...").white().bold() ); println!(); // Signal to main.rs to call start_channels after wizard returns std::env::set_var("ZEROCLAW_AUTOSTART_CHANNELS", "1"); } } Ok(config) } /// Interactive repair flow: rerun channel setup only without redoing full onboarding. pub fn run_channels_repair_wizard() -> Result { println!("{}", style(BANNER).cyan().bold()); println!( " {}", style("Channels Repair — update channel tokens and allowlists only") .white() .bold() ); println!(); let mut config = Config::load_or_init()?; print_step(1, 1, "Channels (How You Talk to ZeroClaw)"); config.channels_config = setup_channels()?; config.save()?; println!(); println!( " {} Channel config saved: {}", style("✓").green().bold(), style(config.config_path.display()).green() ); let has_channels = config.channels_config.telegram.is_some() || config.channels_config.discord.is_some() || config.channels_config.slack.is_some() || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() || config.channels_config.dingtalk.is_some(); if has_channels && config.api_key.is_some() { let launch: bool = Confirm::new() .with_prompt(format!( " {} Launch channels now? (connected channels → AI → reply)", style("🚀").cyan() )) .default(true) .interact()?; if launch { println!(); println!( " {} {}", style("⚡").cyan(), style("Starting channel server...").white().bold() ); println!(); // Signal to main.rs to call start_channels after wizard returns std::env::set_var("ZEROCLAW_AUTOSTART_CHANNELS", "1"); } } Ok(config) } // ── Quick setup (zero prompts) ─────────────────────────────────── /// Non-interactive setup: generates a sensible default config instantly. /// Use `zeroclaw onboard` or `zeroclaw onboard --api-key sk-... --provider openrouter --memory sqlite|lucid`. /// Use `zeroclaw onboard --interactive` for the full wizard. fn backend_key_from_choice(choice: usize) -> &'static str { selectable_memory_backends() .get(choice) .map_or(default_memory_backend_key(), |backend| backend.key) } fn memory_config_defaults_for_backend(backend: &str) -> MemoryConfig { let profile = memory_backend_profile(backend); MemoryConfig { backend: backend.to_string(), auto_save: profile.auto_save_default, hygiene_enabled: profile.uses_sqlite_hygiene, archive_after_days: if profile.uses_sqlite_hygiene { 7 } else { 0 }, purge_after_days: if profile.uses_sqlite_hygiene { 30 } else { 0 }, conversation_retention_days: 30, embedding_provider: "none".to_string(), embedding_model: "text-embedding-3-small".to_string(), embedding_dimensions: 1536, vector_weight: 0.7, keyword_weight: 0.3, embedding_cache_size: if profile.uses_sqlite_hygiene { 10000 } else { 0 }, chunk_max_tokens: 512, response_cache_enabled: false, response_cache_ttl_minutes: 60, response_cache_max_entries: 5_000, snapshot_enabled: false, snapshot_on_hygiene: false, auto_hydrate: true, } } #[allow(clippy::too_many_lines)] pub fn run_quick_setup( credential_override: Option<&str>, provider: Option<&str>, memory_backend: Option<&str>, ) -> Result { println!("{}", style(BANNER).cyan().bold()); println!( " {}", style("Quick Setup — generating config with sensible defaults...") .white() .bold() ); println!(); let home = directories::UserDirs::new() .map(|u| u.home_dir().to_path_buf()) .context("Could not find home directory")?; let zeroclaw_dir = home.join(".zeroclaw"); 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")?; let provider_name = provider.unwrap_or("openrouter").to_string(); let model = default_model_for_provider(&provider_name); let memory_backend_name = memory_backend .unwrap_or(default_memory_backend_key()) .to_string(); // Create memory config based on backend choice let memory_config = memory_config_defaults_for_backend(&memory_backend_name); let config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), api_key: credential_override.map(String::from), api_url: None, default_provider: Some(provider_name.clone()), default_model: Some(model.clone()), default_temperature: 0.7, observability: ObservabilityConfig::default(), autonomy: AutonomyConfig::default(), runtime: RuntimeConfig::default(), reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), agent: crate::config::schema::AgentConfig::default(), model_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), cron: crate::config::CronConfig::default(), channels_config: ChannelsConfig::default(), memory: memory_config, tunnel: crate::config::TunnelConfig::default(), gateway: crate::config::GatewayConfig::default(), composio: ComposioConfig::default(), secrets: SecretsConfig::default(), browser: BrowserConfig::default(), http_request: crate::config::HttpRequestConfig::default(), identity: crate::config::IdentityConfig::default(), cost: crate::config::CostConfig::default(), peripherals: crate::config::PeripheralsConfig::default(), agents: std::collections::HashMap::new(), hardware: crate::config::HardwareConfig::default(), }; config.save()?; // Scaffold minimal workspace files let default_ctx = ProjectContext { user_name: std::env::var("USER").unwrap_or_else(|_| "User".into()), timezone: "UTC".into(), agent_name: "ZeroClaw".into(), communication_style: "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing." .into(), }; scaffold_workspace(&workspace_dir, &default_ctx)?; println!( " {} Workspace: {}", style("✓").green().bold(), style(workspace_dir.display()).green() ); println!( " {} Provider: {}", style("✓").green().bold(), style(&provider_name).green() ); println!( " {} Model: {}", style("✓").green().bold(), style(&model).green() ); println!( " {} API Key: {}", style("✓").green().bold(), if credential_override.is_some() { style("set").green() } else { style("not set (use --api-key or edit config.toml)").yellow() } ); println!( " {} Security: {}", style("✓").green().bold(), style("Supervised (workspace-scoped)").green() ); println!( " {} Memory: {} (auto-save: {})", style("✓").green().bold(), style(&memory_backend_name).green(), if memory_backend_name == "none" { "off" } else { "on" } ); println!( " {} Secrets: {}", style("✓").green().bold(), style("encrypted").green() ); println!( " {} Gateway: {}", style("✓").green().bold(), style("pairing required (127.0.0.1:8080)").green() ); println!( " {} Tunnel: {}", style("✓").green().bold(), style("none (local only)").dim() ); println!( " {} Composio: {}", style("✓").green().bold(), style("disabled (sovereign mode)").dim() ); println!(); println!( " {} {}", style("Config saved:").white().bold(), style(config_path.display()).green() ); println!(); println!(" {}", style("Next steps:").white().bold()); if credential_override.is_none() { println!(" 1. Set your API key: export OPENROUTER_API_KEY=\"sk-...\""); println!(" 2. Or edit: ~/.zeroclaw/config.toml"); println!(" 3. Chat: zeroclaw agent -m \"Hello!\""); println!(" 4. Gateway: zeroclaw gateway"); } else { println!(" 1. Chat: zeroclaw agent -m \"Hello!\""); println!(" 2. Gateway: zeroclaw gateway"); println!(" 3. Status: zeroclaw status"); } println!(); Ok(config) } fn canonical_provider_name(provider_name: &str) -> &str { match provider_name { "grok" => "xai", "together" => "together-ai", "google" | "google-gemini" => "gemini", _ => provider_name, } } /// Pick a sensible default model for the given provider. const MINIMAX_ONBOARD_MODELS: [(&str, &str); 5] = [ ("MiniMax-M2.5", "MiniMax M2.5 (latest, recommended)"), ("MiniMax-M2.5-highspeed", "MiniMax M2.5 High-Speed (faster)"), ("MiniMax-M2.1", "MiniMax M2.1 (stable)"), ("MiniMax-M2.1-highspeed", "MiniMax M2.1 High-Speed (faster)"), ("MiniMax-M2", "MiniMax M2 (legacy)"), ]; fn default_model_for_provider(provider: &str) -> String { match canonical_provider_name(provider) { "anthropic" => "claude-sonnet-4-5-20250929".into(), "openai" => "gpt-5.2".into(), "glm" | "zhipu" | "zai" | "z.ai" => "glm-5".into(), "minimax" => "MiniMax-M2.5".into(), "ollama" => "llama3.2".into(), "groq" => "llama-3.3-70b-versatile".into(), "deepseek" => "deepseek-chat".into(), "gemini" => "gemini-2.5-pro".into(), _ => "anthropic/claude-sonnet-4.5".into(), } } fn curated_models_for_provider(provider_name: &str) -> Vec<(String, String)> { match canonical_provider_name(provider_name) { "openrouter" => vec![ ( "anthropic/claude-sonnet-4.5".to_string(), "Claude Sonnet 4.5 (balanced, recommended)".to_string(), ), ( "openai/gpt-5.2".to_string(), "GPT-5.2 (latest flagship)".to_string(), ), ( "openai/gpt-5-mini".to_string(), "GPT-5 mini (fast, cost-efficient)".to_string(), ), ( "google/gemini-3-pro-preview".to_string(), "Gemini 3 Pro Preview (frontier reasoning)".to_string(), ), ( "x-ai/grok-4.1-fast".to_string(), "Grok 4.1 Fast (reasoning + speed)".to_string(), ), ( "deepseek/deepseek-v3.2".to_string(), "DeepSeek V3.2 (agentic + affordable)".to_string(), ), ( "meta-llama/llama-4-maverick".to_string(), "Llama 4 Maverick (open model)".to_string(), ), ], "anthropic" => vec![ ( "claude-sonnet-4-5-20250929".to_string(), "Claude Sonnet 4.5 (balanced, recommended)".to_string(), ), ( "claude-opus-4-6".to_string(), "Claude Opus 4.6 (best quality)".to_string(), ), ( "claude-haiku-4-5-20251001".to_string(), "Claude Haiku 4.5 (fastest, cheapest)".to_string(), ), ], "openai" => vec![ ( "gpt-5.2".to_string(), "GPT-5.2 (latest coding/agentic flagship)".to_string(), ), ( "gpt-5-mini".to_string(), "GPT-5 mini (faster, cheaper)".to_string(), ), ( "gpt-5-nano".to_string(), "GPT-5 nano (lowest latency/cost)".to_string(), ), ( "gpt-5.2-codex".to_string(), "GPT-5.2 Codex (agentic coding)".to_string(), ), ], "venice" => vec![ ( "llama-3.3-70b".to_string(), "Llama 3.3 70B (default, fast)".to_string(), ), ( "claude-opus-45".to_string(), "Claude Opus 4.5 via Venice (strongest)".to_string(), ), ( "llama-3.1-405b".to_string(), "Llama 3.1 405B (largest open source)".to_string(), ), ], "groq" => vec![ ( "llama-3.3-70b-versatile".to_string(), "Llama 3.3 70B (fast, recommended)".to_string(), ), ( "openai/gpt-oss-120b".to_string(), "GPT-OSS 120B (strong open-weight)".to_string(), ), ( "openai/gpt-oss-20b".to_string(), "GPT-OSS 20B (cost-efficient open-weight)".to_string(), ), ], "mistral" => vec![ ( "mistral-large-latest".to_string(), "Mistral Large (latest flagship)".to_string(), ), ( "mistral-medium-latest".to_string(), "Mistral Medium (balanced)".to_string(), ), ( "codestral-latest".to_string(), "Codestral (code-focused)".to_string(), ), ( "devstral-latest".to_string(), "Devstral (software engineering specialist)".to_string(), ), ], "deepseek" => vec![ ( "deepseek-chat".to_string(), "DeepSeek Chat (mapped to V3.2 non-thinking)".to_string(), ), ( "deepseek-reasoner".to_string(), "DeepSeek Reasoner (mapped to V3.2 thinking)".to_string(), ), ], "xai" => vec![ ( "grok-4-1-fast-reasoning".to_string(), "Grok 4.1 Fast Reasoning (recommended)".to_string(), ), ( "grok-4-1-fast-non-reasoning".to_string(), "Grok 4.1 Fast Non-Reasoning (low latency)".to_string(), ), ( "grok-code-fast-1".to_string(), "Grok Code Fast 1 (coding specialist)".to_string(), ), ("grok-4".to_string(), "Grok 4 (max quality)".to_string()), ], "perplexity" => vec![ ( "sonar-pro".to_string(), "Sonar Pro (flagship web-grounded model)".to_string(), ), ( "sonar-reasoning-pro".to_string(), "Sonar Reasoning Pro (complex multi-step reasoning)".to_string(), ), ( "sonar-deep-research".to_string(), "Sonar Deep Research (long-form research)".to_string(), ), ("sonar".to_string(), "Sonar (search, fast)".to_string()), ], "fireworks" => vec![ ( "accounts/fireworks/models/llama-v3p3-70b-instruct".to_string(), "Llama 3.3 70B".to_string(), ), ( "accounts/fireworks/models/mixtral-8x22b-instruct".to_string(), "Mixtral 8x22B".to_string(), ), ], "together-ai" => vec![ ( "meta-llama/Llama-3.3-70B-Instruct-Turbo".to_string(), "Llama 3.3 70B Instruct Turbo (recommended)".to_string(), ), ( "moonshotai/Kimi-K2.5".to_string(), "Kimi K2.5 (reasoning + coding)".to_string(), ), ( "deepseek-ai/DeepSeek-V3.1".to_string(), "DeepSeek V3.1 (strong value)".to_string(), ), ], "cohere" => vec![ ( "command-a-03-2025".to_string(), "Command A (flagship enterprise model)".to_string(), ), ( "command-a-reasoning-08-2025".to_string(), "Command A Reasoning (agentic reasoning)".to_string(), ), ( "command-r-08-2024".to_string(), "Command R (stable fast baseline)".to_string(), ), ], "moonshot" => vec![ ( "kimi-latest".to_string(), "Kimi Latest (rolling latest assistant model)".to_string(), ), ( "kimi-k2-0905-preview".to_string(), "Kimi K2 0905 Preview (strong coding)".to_string(), ), ( "kimi-thinking-preview".to_string(), "Kimi Thinking Preview (deep reasoning)".to_string(), ), ], "glm" | "zhipu" | "zai" | "z.ai" => vec![ ( "glm-4.7".to_string(), "GLM-4.7 (latest flagship)".to_string(), ), ("glm-5".to_string(), "GLM-5 (high reasoning)".to_string()), ( "glm-4-plus".to_string(), "GLM-4 Plus (stable baseline)".to_string(), ), ], "minimax" => vec![ ( "MiniMax-M2.5".to_string(), "MiniMax M2.5 (latest flagship)".to_string(), ), ( "MiniMax-M2.1".to_string(), "MiniMax M2.1 (strong coding/reasoning)".to_string(), ), ( "MiniMax-M2.1-lightning".to_string(), "MiniMax M2.1 Lightning (fast)".to_string(), ), ], "ollama" => vec![ ( "llama3.2".to_string(), "Llama 3.2 (recommended local)".to_string(), ), ("mistral".to_string(), "Mistral 7B".to_string()), ("codellama".to_string(), "Code Llama".to_string()), ("phi3".to_string(), "Phi-3 (small, fast)".to_string()), ], "gemini" => vec![ ( "gemini-3-pro-preview".to_string(), "Gemini 3 Pro Preview (latest frontier reasoning)".to_string(), ), ( "gemini-2.5-pro".to_string(), "Gemini 2.5 Pro (stable reasoning)".to_string(), ), ( "gemini-2.5-flash".to_string(), "Gemini 2.5 Flash (best price/performance)".to_string(), ), ( "gemini-2.5-flash-lite".to_string(), "Gemini 2.5 Flash-Lite (lowest cost)".to_string(), ), ], _ => vec![("default".to_string(), "Default model".to_string())], } } fn supports_live_model_fetch(provider_name: &str) -> bool { matches!( canonical_provider_name(provider_name), "openrouter" | "openai" | "anthropic" | "groq" | "mistral" | "deepseek" | "xai" | "together-ai" | "gemini" | "ollama" ) } fn build_model_fetch_client() -> Result { reqwest::blocking::Client::builder() .timeout(Duration::from_secs(8)) .connect_timeout(Duration::from_secs(4)) .build() .context("failed to build model-fetch HTTP client") } fn normalize_model_ids(ids: Vec) -> Vec { let mut unique = BTreeSet::new(); for id in ids { let trimmed = id.trim(); if !trimmed.is_empty() { unique.insert(trimmed.to_string()); } } unique.into_iter().collect() } fn parse_openai_compatible_model_ids(payload: &Value) -> Vec { let mut models = Vec::new(); if let Some(data) = payload.get("data").and_then(Value::as_array) { for model in data { if let Some(id) = model.get("id").and_then(Value::as_str) { models.push(id.to_string()); } } } else if let Some(data) = payload.as_array() { for model in data { if let Some(id) = model.get("id").and_then(Value::as_str) { models.push(id.to_string()); } } } normalize_model_ids(models) } fn parse_gemini_model_ids(payload: &Value) -> Vec { let Some(models) = payload.get("models").and_then(Value::as_array) else { return Vec::new(); }; let mut ids = Vec::new(); for model in models { let supports_generate_content = model .get("supportedGenerationMethods") .and_then(Value::as_array) .is_none_or(|methods| { methods .iter() .any(|method| method.as_str() == Some("generateContent")) }); if !supports_generate_content { continue; } if let Some(name) = model.get("name").and_then(Value::as_str) { ids.push(name.trim_start_matches("models/").to_string()); } } normalize_model_ids(ids) } fn parse_ollama_model_ids(payload: &Value) -> Vec { let Some(models) = payload.get("models").and_then(Value::as_array) else { return Vec::new(); }; let mut ids = Vec::new(); for model in models { if let Some(name) = model.get("name").and_then(Value::as_str) { ids.push(name.to_string()); } } normalize_model_ids(ids) } fn fetch_openai_compatible_models(endpoint: &str, api_key: Option<&str>) -> Result> { let Some(api_key) = api_key else { return Ok(Vec::new()); }; let client = build_model_fetch_client()?; let payload: Value = client .get(endpoint) .bearer_auth(api_key) .send() .and_then(reqwest::blocking::Response::error_for_status) .with_context(|| format!("model fetch failed: GET {endpoint}"))? .json() .context("failed to parse model list response")?; Ok(parse_openai_compatible_model_ids(&payload)) } fn fetch_openrouter_models(api_key: Option<&str>) -> Result> { let client = build_model_fetch_client()?; let mut request = client.get("https://openrouter.ai/api/v1/models"); if let Some(api_key) = api_key { request = request.bearer_auth(api_key); } let payload: Value = request .send() .and_then(reqwest::blocking::Response::error_for_status) .context("model fetch failed: GET https://openrouter.ai/api/v1/models")? .json() .context("failed to parse OpenRouter model list response")?; Ok(parse_openai_compatible_model_ids(&payload)) } fn fetch_anthropic_models(api_key: Option<&str>) -> Result> { let Some(api_key) = api_key else { return Ok(Vec::new()); }; let client = build_model_fetch_client()?; let mut request = client .get("https://api.anthropic.com/v1/models") .header("anthropic-version", "2023-06-01"); if api_key.starts_with("sk-ant-oat01-") { request = request .header("Authorization", format!("Bearer {api_key}")) .header("anthropic-beta", "oauth-2025-04-20"); } else { request = request.header("x-api-key", api_key); } let response = request .send() .context("model fetch failed: GET https://api.anthropic.com/v1/models")?; let status = response.status(); if !status.is_success() { let body = response.text().unwrap_or_default(); bail!("Anthropic model list request failed (HTTP {status}): {body}"); } let payload: Value = response .json() .context("failed to parse Anthropic model list response")?; Ok(parse_openai_compatible_model_ids(&payload)) } fn fetch_gemini_models(api_key: Option<&str>) -> Result> { let Some(api_key) = api_key else { return Ok(Vec::new()); }; let client = build_model_fetch_client()?; let payload: Value = client .get("https://generativelanguage.googleapis.com/v1beta/models") .query(&[("key", api_key), ("pageSize", "200")]) .send() .and_then(reqwest::blocking::Response::error_for_status) .context("model fetch failed: GET Gemini models")? .json() .context("failed to parse Gemini model list response")?; Ok(parse_gemini_model_ids(&payload)) } fn fetch_ollama_models() -> Result> { let client = build_model_fetch_client()?; let payload: Value = client .get("http://localhost:11434/api/tags") .send() .and_then(reqwest::blocking::Response::error_for_status) .context("model fetch failed: GET http://localhost:11434/api/tags")? .json() .context("failed to parse Ollama model list response")?; Ok(parse_ollama_model_ids(&payload)) } fn fetch_live_models_for_provider(provider_name: &str, api_key: &str) -> Result> { let provider_name = canonical_provider_name(provider_name); let api_key = if api_key.trim().is_empty() { std::env::var(provider_env_var(provider_name)) .ok() .or_else(|| { // Anthropic also accepts OAuth setup-tokens via ANTHROPIC_OAUTH_TOKEN if provider_name == "anthropic" { std::env::var("ANTHROPIC_OAUTH_TOKEN").ok() } else { None } }) .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } else { Some(api_key.trim().to_string()) }; let models = match provider_name { "openrouter" => fetch_openrouter_models(api_key.as_deref())?, "openai" => { fetch_openai_compatible_models("https://api.openai.com/v1/models", api_key.as_deref())? } "groq" => fetch_openai_compatible_models( "https://api.groq.com/openai/v1/models", api_key.as_deref(), )?, "mistral" => { fetch_openai_compatible_models("https://api.mistral.ai/v1/models", api_key.as_deref())? } "deepseek" => fetch_openai_compatible_models( "https://api.deepseek.com/v1/models", api_key.as_deref(), )?, "xai" => fetch_openai_compatible_models("https://api.x.ai/v1/models", api_key.as_deref())?, "together-ai" => fetch_openai_compatible_models( "https://api.together.xyz/v1/models", api_key.as_deref(), )?, "anthropic" => fetch_anthropic_models(api_key.as_deref())?, "gemini" => fetch_gemini_models(api_key.as_deref())?, "ollama" => fetch_ollama_models()?, _ => Vec::new(), }; Ok(models) } #[derive(Debug, Clone, Serialize, Deserialize)] struct ModelCacheEntry { provider: String, fetched_at_unix: u64, models: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] struct ModelCacheState { entries: Vec, } #[derive(Debug, Clone)] struct CachedModels { models: Vec, age_secs: u64, } fn model_cache_path(workspace_dir: &Path) -> PathBuf { workspace_dir.join("state").join(MODEL_CACHE_FILE) } fn now_unix_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_secs()) } 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) .with_context(|| format!("failed to read model cache at {}", path.display()))?; match serde_json::from_str::(&raw) { Ok(state) => Ok(state), Err(_) => Ok(ModelCacheState::default()), } } 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(|| { format!( "failed to create model cache directory {}", parent.display() ) })?; } let json = serde_json::to_vec_pretty(state).context("failed to serialize model cache")?; fs::write(&path, json) .with_context(|| format!("failed to write model cache at {}", path.display()))?; Ok(()) } fn cache_live_models_for_provider( workspace_dir: &Path, provider_name: &str, models: &[String], ) -> Result<()> { let normalized_models = normalize_model_ids(models.to_vec()); if normalized_models.is_empty() { return Ok(()); } let mut state = load_model_cache_state(workspace_dir)?; let now = now_unix_secs(); if let Some(entry) = state .entries .iter_mut() .find(|entry| entry.provider == provider_name) { entry.fetched_at_unix = now; entry.models = normalized_models; } else { state.entries.push(ModelCacheEntry { provider: provider_name.to_string(), fetched_at_unix: now, models: normalized_models, }); } save_model_cache_state(workspace_dir, &state) } 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 now = now_unix_secs(); let Some(entry) = state .entries .into_iter() .find(|entry| entry.provider == provider_name) else { return Ok(None); }; if entry.models.is_empty() { return Ok(None); } let age_secs = now.saturating_sub(entry.fetched_at_unix); if ttl_secs.is_some_and(|ttl| age_secs > ttl) { return Ok(None); } Ok(Some(CachedModels { models: entry.models, age_secs, })) } 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)) } 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) } fn humanize_age(age_secs: u64) -> String { if age_secs < 60 { format!("{age_secs}s") } else if age_secs < 60 * 60 { format!("{}m", age_secs / 60) } else { format!("{}h", age_secs / (60 * 60)) } } fn build_model_options(model_ids: Vec, source: &str) -> Vec<(String, String)> { model_ids .into_iter() .map(|model_id| { let label = format!("{model_id} ({source})"); (model_id, label) }) .collect() } fn print_model_preview(models: &[String]) { for model in models.iter().take(MODEL_PREVIEW_LIMIT) { println!(" {} {model}", style("-")); } if models.len() > MODEL_PREVIEW_LIMIT { println!( " {} ... and {} more", style("-"), models.len() - MODEL_PREVIEW_LIMIT ); } } pub fn run_models_refresh( config: &Config, provider_override: Option<&str>, force: bool, ) -> Result<()> { let provider_name = provider_override .or(config.default_provider.as_deref()) .unwrap_or("openrouter") .trim() .to_string(); if provider_name.is_empty() { anyhow::bail!("Provider name cannot be empty"); } if !supports_live_model_fetch(&provider_name) { anyhow::bail!("Provider '{provider_name}' does not support live model discovery yet"); } if !force { if let Some(cached) = load_cached_models_for_provider( &config.workspace_dir, &provider_name, MODEL_CACHE_TTL_SECS, )? { println!( "Using cached model list for '{}' (updated {} ago):", provider_name, humanize_age(cached.age_secs) ); print_model_preview(&cached.models); println!(); println!( "Tip: run `zeroclaw models refresh --force --provider {}` to fetch latest now.", provider_name ); return Ok(()); } } let api_key = config.api_key.clone().unwrap_or_default(); match fetch_live_models_for_provider(&provider_name, &api_key) { Ok(models) if !models.is_empty() => { cache_live_models_for_provider(&config.workspace_dir, &provider_name, &models)?; println!( "Refreshed '{}' model cache with {} models.", provider_name, models.len() ); print_model_preview(&models); Ok(()) } Ok(_) => { if let Some(stale_cache) = load_any_cached_models_for_provider(&config.workspace_dir, &provider_name)? { println!( "Provider returned no models; using stale cache (updated {} ago):", humanize_age(stale_cache.age_secs) ); print_model_preview(&stale_cache.models); return Ok(()); } anyhow::bail!("Provider '{}' returned an empty model list", provider_name) } Err(error) => { if let Some(stale_cache) = load_any_cached_models_for_provider(&config.workspace_dir, &provider_name)? { println!( "Live refresh failed ({}). Falling back to stale cache (updated {} ago):", error, humanize_age(stale_cache.age_secs) ); print_model_preview(&stale_cache.models); return Ok(()); } Err(error) .with_context(|| format!("failed to refresh models for provider '{provider_name}'")) } } } // ── Step helpers ───────────────────────────────────────────────── fn print_step(current: u8, total: u8, title: &str) { println!(); println!( " {} {}", style(format!("[{current}/{total}]")).cyan().bold(), style(title).white().bold() ); println!(" {}", style("─".repeat(50)).dim()); } fn print_bullet(text: &str) { println!(" {} {}", style("›").cyan(), text); } // ── Step 1: Workspace ──────────────────────────────────────────── 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")?; let default_dir = home.join(".zeroclaw"); print_bullet(&format!( "Default location: {}", style(default_dir.display()).green() )); let use_default = Confirm::new() .with_prompt(" Use default workspace location?") .default(true) .interact()?; let zeroclaw_dir = if use_default { default_dir } else { let custom: String = Input::new() .with_prompt(" Enter workspace path") .interact_text()?; let expanded = shellexpand::tilde(&custom).to_string(); PathBuf::from(expanded) }; 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")?; println!( " {} Workspace: {}", style("✓").green().bold(), style(workspace_dir.display()).green() ); Ok((workspace_dir, config_path)) } // ── Step 2: Provider & API Key ─────────────────────────────────── #[allow(clippy::too_many_lines)] fn setup_provider(workspace_dir: &Path) -> Result<(String, String, String)> { // ── Tier selection ── let tiers = vec![ "⭐ Recommended (OpenRouter, Venice, Anthropic, OpenAI, Gemini)", "⚡ Fast inference (Groq, Fireworks, Together AI, NVIDIA NIM)", "🌐 Gateway / proxy (Vercel AI, Cloudflare AI, Amazon Bedrock)", "🔬 Specialized (Moonshot/Kimi, GLM/Zhipu, MiniMax, Qianfan, Z.AI, Synthetic, OpenCode Zen, Cohere)", "🏠 Local / private (Ollama — no API key needed)", "🔧 Custom — bring your own OpenAI-compatible API", ]; let tier_idx = Select::new() .with_prompt(" Select provider category") .items(&tiers) .default(0) .interact()?; let providers: Vec<(&str, &str)> = match tier_idx { 0 => vec![ ( "openrouter", "OpenRouter — 200+ models, 1 API key (recommended)", ), ("venice", "Venice AI — privacy-first (Llama, Opus)"), ("anthropic", "Anthropic — Claude Sonnet & Opus (direct)"), ("openai", "OpenAI — GPT-4o, o1, GPT-5 (direct)"), ("deepseek", "DeepSeek — V3 & R1 (affordable)"), ("mistral", "Mistral — Large & Codestral"), ("xai", "xAI — Grok 3 & 4"), ("perplexity", "Perplexity — search-augmented AI"), ( "gemini", "Google Gemini — Gemini 2.0 Flash & Pro (supports CLI auth)", ), ], 1 => vec![ ("groq", "Groq — ultra-fast LPU inference"), ("fireworks", "Fireworks AI — fast open-source inference"), ("together-ai", "Together AI — open-source model hosting"), ("nvidia", "NVIDIA NIM — DeepSeek, Llama, & more"), ], 2 => vec![ ("vercel", "Vercel AI Gateway"), ("cloudflare", "Cloudflare AI Gateway"), ("bedrock", "Amazon Bedrock — AWS managed models"), ], 3 => vec![ ("moonshot", "Moonshot — Kimi & Kimi Coding"), ("glm", "GLM — ChatGLM / Zhipu models"), ("minimax", "MiniMax — MiniMax AI models"), ("qianfan", "Qianfan — Baidu AI models"), ("zai", "Z.AI — Z.AI inference"), ("synthetic", "Synthetic — Synthetic AI models"), ("opencode", "OpenCode Zen — code-focused AI"), ("cohere", "Cohere — Command R+ & embeddings"), ], 4 => vec![("ollama", "Ollama — local models (Llama, Mistral, Phi)")], _ => vec![], // Custom — handled below }; // ── Custom / BYOP flow ── if providers.is_empty() { println!(); println!( " {} {}", style("Custom Provider Setup").white().bold(), style("— any OpenAI-compatible API").dim() ); print_bullet("ZeroClaw works with ANY API that speaks the OpenAI chat completions format."); print_bullet("Examples: LiteLLM, LocalAI, vLLM, text-generation-webui, LM Studio, etc."); println!(); let base_url: String = Input::new() .with_prompt(" API base URL (e.g. http://localhost:1234 or https://my-api.com)") .interact_text()?; let base_url = base_url.trim().trim_end_matches('/').to_string(); if base_url.is_empty() { anyhow::bail!("Custom provider requires a base URL."); } let api_key: String = Input::new() .with_prompt(" API key (or Enter to skip if not needed)") .allow_empty(true) .interact_text()?; let model: String = Input::new() .with_prompt(" Model name (e.g. llama3, gpt-4o, mistral)") .default("default".into()) .interact_text()?; let provider_name = format!("custom:{base_url}"); println!( " {} Provider: {} | Model: {}", style("✓").green().bold(), style(&provider_name).green(), style(&model).green() ); return Ok((provider_name, api_key, model)); } let provider_labels: Vec<&str> = providers.iter().map(|(_, label)| *label).collect(); let provider_idx = Select::new() .with_prompt(" Select your AI provider") .items(&provider_labels) .default(0) .interact()?; let provider_name = providers[provider_idx].0; // ── API key ── let api_key = if provider_name == "ollama" { print_bullet("Ollama runs locally — no API key needed!"); String::new() } else if canonical_provider_name(provider_name) == "gemini" { // Special handling for Gemini: check for CLI auth first if crate::providers::gemini::GeminiProvider::has_cli_credentials() { print_bullet(&format!( "{} Gemini CLI credentials detected! You can skip the API key.", style("✓").green().bold() )); print_bullet("ZeroClaw will reuse your existing Gemini CLI authentication."); println!(); let use_cli: bool = dialoguer::Confirm::new() .with_prompt(" Use existing Gemini CLI authentication?") .default(true) .interact()?; if use_cli { println!( " {} Using Gemini CLI OAuth tokens", style("✓").green().bold() ); String::new() // Empty key = will use CLI tokens } else { print_bullet("Get your API key at: https://aistudio.google.com/app/apikey"); Input::new() .with_prompt(" Paste your Gemini API key") .allow_empty(true) .interact_text()? } } else if std::env::var("GEMINI_API_KEY").is_ok() { print_bullet(&format!( "{} GEMINI_API_KEY environment variable detected!", style("✓").green().bold() )); String::new() } else { print_bullet("Get your API key at: https://aistudio.google.com/app/apikey"); print_bullet("Or run `gemini` CLI to authenticate (tokens will be reused)."); println!(); Input::new() .with_prompt(" Paste your Gemini API key (or press Enter to skip)") .allow_empty(true) .interact_text()? } } else if canonical_provider_name(provider_name) == "anthropic" { if std::env::var("ANTHROPIC_OAUTH_TOKEN").is_ok() { print_bullet(&format!( "{} ANTHROPIC_OAUTH_TOKEN environment variable detected!", style("✓").green().bold() )); String::new() } else if std::env::var("ANTHROPIC_API_KEY").is_ok() { print_bullet(&format!( "{} ANTHROPIC_API_KEY environment variable detected!", style("✓").green().bold() )); String::new() } else { print_bullet(&format!( "Get your API key at: {}", style("https://console.anthropic.com/settings/keys") .cyan() .underlined() )); print_bullet("Or run `claude setup-token` to get an OAuth setup-token."); println!(); let key: String = Input::new() .with_prompt(" Paste your API key or setup-token (or press Enter to skip)") .allow_empty(true) .interact_text()?; if key.is_empty() { print_bullet(&format!( "Skipped. Set {} or {} or edit config.toml later.", style("ANTHROPIC_API_KEY").yellow(), style("ANTHROPIC_OAUTH_TOKEN").yellow() )); } key } } else { let key_url = match provider_name { "openrouter" => "https://openrouter.ai/keys", "openai" => "https://platform.openai.com/api-keys", "venice" => "https://venice.ai/settings/api", "groq" => "https://console.groq.com/keys", "mistral" => "https://console.mistral.ai/api-keys", "deepseek" => "https://platform.deepseek.com/api_keys", "together-ai" => "https://api.together.xyz/settings/api-keys", "fireworks" => "https://fireworks.ai/account/api-keys", "perplexity" => "https://www.perplexity.ai/settings/api", "xai" => "https://console.x.ai", "cohere" => "https://dashboard.cohere.com/api-keys", "moonshot" => "https://platform.moonshot.cn/console/api-keys", "glm" | "zhipu" => "https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys", "zai" | "z.ai" => "https://platform.z.ai/", "minimax" => "https://www.minimaxi.com/user-center/basic-information", "vercel" => "https://vercel.com/account/tokens", "cloudflare" => "https://dash.cloudflare.com/profile/api-tokens", "nvidia" | "nvidia-nim" | "build.nvidia.com" => "https://build.nvidia.com/", "bedrock" => "https://console.aws.amazon.com/iam", "gemini" => "https://aistudio.google.com/app/apikey", _ => "", }; println!(); if !key_url.is_empty() { print_bullet(&format!( "Get your API key at: {}", style(key_url).cyan().underlined() )); } print_bullet("You can also set it later via env var or config file."); println!(); let key: String = Input::new() .with_prompt(" Paste your API key (or press Enter to skip)") .allow_empty(true) .interact_text()?; if key.is_empty() { let env_var = provider_env_var(provider_name); print_bullet(&format!( "Skipped. Set {} or edit config.toml later.", style(env_var).yellow() )); } key }; // ── Model selection ── let models: Vec<(&str, &str)> = match provider_name { "openrouter" => vec![ ( "anthropic/claude-sonnet-4", "Claude Sonnet 4 (balanced, recommended)", ), ( "anthropic/claude-3.5-sonnet", "Claude 3.5 Sonnet (fast, affordable)", ), ("openai/gpt-4o", "GPT-4o (OpenAI flagship)"), ("openai/gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), ( "google/gemini-2.0-flash-001", "Gemini 2.0 Flash (Google, fast)", ), ( "meta-llama/llama-3.3-70b-instruct", "Llama 3.3 70B (open source)", ), ("deepseek/deepseek-chat", "DeepSeek Chat (affordable)"), ], "anthropic" => vec![ ( "claude-sonnet-4-20250514", "Claude Sonnet 4 (balanced, recommended)", ), ("claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet (fast)"), ( "claude-3-5-haiku-20241022", "Claude 3.5 Haiku (fastest, cheapest)", ), ], "openai" => vec![ ("gpt-4o", "GPT-4o (flagship)"), ("gpt-4o-mini", "GPT-4o Mini (fast, cheap)"), ("o1-mini", "o1-mini (reasoning)"), ], "venice" => vec![ ("llama-3.3-70b", "Llama 3.3 70B (default, fast)"), ("claude-opus-45", "Claude Opus 4.5 via Venice (strongest)"), ("llama-3.1-405b", "Llama 3.1 405B (largest open source)"), ], "groq" => vec![ ( "llama-3.3-70b-versatile", "Llama 3.3 70B (fast, recommended)", ), ("llama-3.1-8b-instant", "Llama 3.1 8B (instant)"), ("mixtral-8x7b-32768", "Mixtral 8x7B (32K context)"), ], "mistral" => vec![ ("mistral-large-latest", "Mistral Large (flagship)"), ("codestral-latest", "Codestral (code-focused)"), ("mistral-small-latest", "Mistral Small (fast, cheap)"), ], "deepseek" => vec![ ("deepseek-chat", "DeepSeek Chat (V3, recommended)"), ("deepseek-reasoner", "DeepSeek Reasoner (R1)"), ], "xai" => vec![ ("grok-3", "Grok 3 (flagship)"), ("grok-3-mini", "Grok 3 Mini (fast)"), ], "perplexity" => vec![ ("sonar-pro", "Sonar Pro (search + reasoning)"), ("sonar", "Sonar (search, fast)"), ], "fireworks" => vec![ ( "accounts/fireworks/models/llama-v3p3-70b-instruct", "Llama 3.3 70B", ), ( "accounts/fireworks/models/mixtral-8x22b-instruct", "Mixtral 8x22B", ), ], "together" => vec![ ( "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo", "Llama 3.1 70B Turbo", ), ( "meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", "Llama 3.1 8B Turbo", ), ("mistralai/Mixtral-8x22B-Instruct-v0.1", "Mixtral 8x22B"), ], "nvidia" | "nvidia-nim" | "build.nvidia.com" => vec![ ("deepseek-ai/DeepSeek-R1", "DeepSeek R1 (reasoning)"), ("meta/llama-3.1-70b-instruct", "Llama 3.1 70B Instruct"), ("mistralai/Mistral-7B-Instruct-v0.3", "Mistral 7B Instruct"), ("meta/llama-3.1-405b-instruct", "Llama 3.1 405B Instruct"), ], "cohere" => vec![ ("command-r-plus", "Command R+ (flagship)"), ("command-r", "Command R (fast)"), ], "moonshot" => vec![ ("moonshot-v1-128k", "Moonshot V1 128K"), ("moonshot-v1-32k", "Moonshot V1 32K"), ], "glm" | "zhipu" | "zai" | "z.ai" => vec![ ("glm-5", "GLM-5 (latest)"), ("glm-4-plus", "GLM-4 Plus (flagship)"), ("glm-4-flash", "GLM-4 Flash (fast)"), ], "minimax" => MINIMAX_ONBOARD_MODELS.to_vec(), "ollama" => vec![ ("llama3.2", "Llama 3.2 (recommended local)"), ("mistral", "Mistral 7B"), ("codellama", "Code Llama"), ("phi3", "Phi-3 (small, fast)"), ], "gemini" | "google" | "google-gemini" => vec![ ("gemini-2.0-flash", "Gemini 2.0 Flash (fast, recommended)"), ( "gemini-2.0-flash-lite", "Gemini 2.0 Flash Lite (fastest, cheapest)", ), ("gemini-1.5-pro", "Gemini 1.5 Pro (best quality)"), ("gemini-1.5-flash", "Gemini 1.5 Flash (balanced)"), ], _ => vec![("default", "Default model")], }; let mut model_options: Vec<(String, String)> = models .into_iter() .map(|(model_id, label)| (model_id.to_string(), label.to_string())) .collect(); let mut live_options: Option> = None; if supports_live_model_fetch(provider_name) { let can_fetch_without_key = matches!(provider_name, "openrouter" | "ollama"); let has_api_key = !api_key.trim().is_empty() || std::env::var(provider_env_var(provider_name)) .ok() .is_some_and(|value| !value.trim().is_empty()); 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)? { let shown_count = cached.models.len().min(LIVE_MODEL_MAX_OPTIONS); print_bullet(&format!( "Found cached models ({shown_count}) updated {} ago.", humanize_age(cached.age_secs) )); live_options = Some(build_model_options( cached .models .into_iter() .take(LIVE_MODEL_MAX_OPTIONS) .collect(), "cached", )); } let should_fetch_now = Confirm::new() .with_prompt(if live_options.is_some() { " Refresh models from provider now?" } else { " Fetch latest models from provider now?" }) .default(live_options.is_none()) .interact()?; if should_fetch_now { match fetch_live_models_for_provider(provider_name, &api_key) { Ok(live_model_ids) if !live_model_ids.is_empty() => { cache_live_models_for_provider( workspace_dir, provider_name, &live_model_ids, )?; let fetched_count = live_model_ids.len(); let shown_count = fetched_count.min(LIVE_MODEL_MAX_OPTIONS); let shown_models: Vec = live_model_ids .into_iter() .take(LIVE_MODEL_MAX_OPTIONS) .collect(); if shown_count < fetched_count { print_bullet(&format!( "Fetched {fetched_count} models. Showing first {shown_count}." )); } else { print_bullet(&format!("Fetched {shown_count} live models.")); } live_options = Some(build_model_options(shown_models, "live")); } Ok(_) => { print_bullet("Provider returned no models; using curated list."); } Err(error) => { print_bullet(&format!( "Live fetch failed ({}); using cached/curated list.", style(error.to_string()).yellow() )); if live_options.is_none() { if let Some(stale) = load_any_cached_models_for_provider(workspace_dir, provider_name)? { print_bullet(&format!( "Loaded stale cache from {} ago.", humanize_age(stale.age_secs) )); live_options = Some(build_model_options( stale .models .into_iter() .take(LIVE_MODEL_MAX_OPTIONS) .collect(), "stale-cache", )); } } } } } } else { print_bullet("No API key detected, so using curated model list."); print_bullet("Tip: add an API key and rerun onboarding to fetch live models."); } } if let Some(live_model_options) = live_options { let source_options = vec![ format!("Provider model list ({})", live_model_options.len()), format!("Curated starter list ({})", model_options.len()), ]; let source_idx = Select::new() .with_prompt(" Model source") .items(&source_options) .default(0) .interact()?; if source_idx == 0 { model_options = live_model_options; } } if model_options.is_empty() { model_options.push(( default_model_for_provider(provider_name), "Provider default model".to_string(), )); } model_options.push(( CUSTOM_MODEL_SENTINEL.to_string(), "Custom model ID (type manually)".to_string(), )); let model_labels: Vec = model_options .iter() .map(|(model_id, label)| format!("{label} — {}", style(model_id).dim())) .collect(); let model_idx = Select::new() .with_prompt(" Select your default model") .items(&model_labels) .default(0) .interact()?; let selected_model = model_options[model_idx].0.clone(); let model = if selected_model == CUSTOM_MODEL_SENTINEL { Input::new() .with_prompt(" Enter custom model ID") .default(default_model_for_provider(provider_name)) .interact_text()? } else { selected_model }; println!( " {} Provider: {} | Model: {}", style("✓").green().bold(), style(provider_name).green(), style(&model).green() ); Ok((provider_name.to_string(), api_key, model)) } /// Map provider name to its conventional env var fn provider_env_var(name: &str) -> &'static str { match canonical_provider_name(name) { "openrouter" => "OPENROUTER_API_KEY", "anthropic" => "ANTHROPIC_API_KEY", "openai" => "OPENAI_API_KEY", "venice" => "VENICE_API_KEY", "groq" => "GROQ_API_KEY", "mistral" => "MISTRAL_API_KEY", "deepseek" => "DEEPSEEK_API_KEY", "xai" => "XAI_API_KEY", "together-ai" => "TOGETHER_API_KEY", "fireworks" | "fireworks-ai" => "FIREWORKS_API_KEY", "perplexity" => "PERPLEXITY_API_KEY", "cohere" => "COHERE_API_KEY", "moonshot" | "kimi" => "MOONSHOT_API_KEY", "glm" | "zhipu" => "GLM_API_KEY", "minimax" => "MINIMAX_API_KEY", "qianfan" | "baidu" => "QIANFAN_API_KEY", "zai" | "z.ai" => "ZAI_API_KEY", "synthetic" => "SYNTHETIC_API_KEY", "opencode" | "opencode-zen" => "OPENCODE_API_KEY", "vercel" | "vercel-ai" => "VERCEL_API_KEY", "cloudflare" | "cloudflare-ai" => "CLOUDFLARE_API_KEY", "bedrock" | "aws-bedrock" => "AWS_ACCESS_KEY_ID", "gemini" => "GEMINI_API_KEY", "nvidia" | "nvidia-nim" | "build.nvidia.com" => "NVIDIA_API_KEY", _ => "API_KEY", } } // ── Step 5: Tool Mode & Security ──────────────────────────────── fn setup_tool_mode() -> Result<(ComposioConfig, SecretsConfig)> { print_bullet("Choose how ZeroClaw connects to external apps."); print_bullet("You can always change this later in config.toml."); println!(); let options = vec![ "Sovereign (local only) — you manage API keys, full privacy (default)", "Composio (managed OAuth) — 1000+ apps via OAuth, no raw keys shared", ]; let choice = Select::new() .with_prompt(" Select tool mode") .items(&options) .default(0) .interact()?; let composio_config = if choice == 1 { println!(); println!( " {} {}", style("Composio Setup").white().bold(), style("— 1000+ OAuth integrations (Gmail, Notion, GitHub, Slack, ...)").dim() ); print_bullet("Get your API key at: https://app.composio.dev/settings"); print_bullet("ZeroClaw uses Composio as a tool — your core agent stays local."); println!(); let api_key: String = Input::new() .with_prompt(" Composio API key (or Enter to skip)") .allow_empty(true) .interact_text()?; if api_key.trim().is_empty() { println!( " {} Skipped — set composio.api_key in config.toml later", style("→").dim() ); ComposioConfig::default() } else { println!( " {} Composio: {} (1000+ OAuth tools available)", style("✓").green().bold(), style("enabled").green() ); ComposioConfig { enabled: true, api_key: Some(api_key), ..ComposioConfig::default() } } } else { println!( " {} Tool mode: {} — full privacy, you own every key", style("✓").green().bold(), style("Sovereign (local only)").green() ); ComposioConfig::default() }; // ── Encrypted secrets ── println!(); print_bullet("ZeroClaw can encrypt API keys stored in config.toml."); print_bullet("A local key file protects against plaintext exposure and accidental leaks."); let encrypt = Confirm::new() .with_prompt(" Enable encrypted secret storage?") .default(true) .interact()?; let secrets_config = SecretsConfig { encrypt }; if encrypt { println!( " {} Secrets: {} — keys encrypted with local key file", style("✓").green().bold(), style("encrypted").green() ); } else { println!( " {} Secrets: {} — keys stored as plaintext (not recommended)", style("✓").green().bold(), style("plaintext").yellow() ); } Ok((composio_config, secrets_config)) } // ── Step 6: Hardware (Physical World) ─────────────────────────── fn setup_hardware() -> Result { print_bullet("ZeroClaw can talk to physical hardware (LEDs, sensors, motors)."); print_bullet("Scanning for connected devices..."); println!(); // ── Auto-discovery ── let devices = hardware::discover_hardware(); if devices.is_empty() { println!( " {} {}", style("ℹ").dim(), style("No hardware devices detected on this system.").dim() ); println!( " {} {}", style("ℹ").dim(), style("You can enable hardware later in config.toml under [hardware].").dim() ); } else { println!( " {} {} device(s) found:", style("✓").green().bold(), devices.len() ); for device in &devices { let detail = device .detail .as_deref() .map(|d| format!(" ({d})")) .unwrap_or_default(); let path = device .device_path .as_deref() .map(|p| format!(" → {p}")) .unwrap_or_default(); println!( " {} {}{}{} [{}]", style("›").cyan(), style(&device.name).green(), style(&detail).dim(), style(&path).dim(), style(device.transport.to_string()).cyan() ); } } println!(); let options = vec![ "🚀 Native — direct GPIO on this Linux board (Raspberry Pi, Orange Pi, etc.)", "🔌 Tethered — control an Arduino/ESP32/Nucleo plugged into USB", "🔬 Debug Probe — flash/read MCUs via SWD/JTAG (probe-rs)", "☁️ Software Only — no hardware access (default)", ]; let recommended = hardware::recommended_wizard_default(&devices); let choice = Select::new() .with_prompt(" How should ZeroClaw interact with the physical world?") .items(&options) .default(recommended) .interact()?; let mut hw_config = hardware::config_from_wizard_choice(choice, &devices); // ── Serial: pick a port if multiple found ── if hw_config.transport_mode() == hardware::HardwareTransport::Serial { let serial_devices: Vec<&hardware::DiscoveredDevice> = devices .iter() .filter(|d| d.transport == hardware::HardwareTransport::Serial) .collect(); if serial_devices.len() > 1 { let port_labels: Vec = serial_devices .iter() .map(|d| { format!( "{} ({})", d.device_path.as_deref().unwrap_or("unknown"), d.name ) }) .collect(); let port_idx = Select::new() .with_prompt(" Multiple serial devices found — select one") .items(&port_labels) .default(0) .interact()?; hw_config.serial_port = serial_devices[port_idx].device_path.clone(); } else if serial_devices.is_empty() { // User chose serial but no device discovered — ask for manual path let manual_port: String = Input::new() .with_prompt(" Serial port path (e.g. /dev/ttyUSB0)") .default("/dev/ttyUSB0".into()) .interact_text()?; hw_config.serial_port = Some(manual_port); } // Baud rate let baud_options = vec![ "115200 (default, recommended)", "9600 (legacy Arduino)", "57600", "230400", "Custom", ]; let baud_idx = Select::new() .with_prompt(" Serial baud rate") .items(&baud_options) .default(0) .interact()?; hw_config.baud_rate = match baud_idx { 1 => 9600, 2 => 57600, 3 => 230_400, 4 => { let custom: String = Input::new() .with_prompt(" Custom baud rate") .default("115200".into()) .interact_text()?; custom.parse::().unwrap_or(115_200) } _ => 115_200, }; } // ── Probe: ask for target chip ── if hw_config.transport_mode() == hardware::HardwareTransport::Probe && hw_config.probe_target.is_none() { let target: String = Input::new() .with_prompt(" Target MCU chip (e.g. STM32F411CEUx, nRF52840_xxAA)") .default("STM32F411CEUx".into()) .interact_text()?; hw_config.probe_target = Some(target); } // ── Datasheet RAG ── if hw_config.enabled { let datasheets = Confirm::new() .with_prompt(" Enable datasheet RAG? (index PDF schematics for AI pin lookups)") .default(true) .interact()?; hw_config.workspace_datasheets = datasheets; } // ── Summary ── if hw_config.enabled { let transport_label = match hw_config.transport_mode() { hardware::HardwareTransport::Native => "Native GPIO".to_string(), hardware::HardwareTransport::Serial => format!( "Serial → {} @ {} baud", hw_config.serial_port.as_deref().unwrap_or("?"), hw_config.baud_rate ), hardware::HardwareTransport::Probe => format!( "Probe (SWD/JTAG) → {}", hw_config.probe_target.as_deref().unwrap_or("?") ), hardware::HardwareTransport::None => "Software Only".to_string(), }; println!( " {} Hardware: {} | datasheets: {}", style("✓").green().bold(), style(&transport_label).green(), if hw_config.workspace_datasheets { style("on").green().to_string() } else { style("off").dim().to_string() } ); } else { println!( " {} Hardware: {}", style("✓").green().bold(), style("disabled (software only)").dim() ); } Ok(hw_config) } // ── Step 6: Project Context ───────────────────────────────────── fn setup_project_context() -> Result { print_bullet("Let's personalize your agent. You can always update these later."); print_bullet("Press Enter to accept defaults."); println!(); let user_name: String = Input::new() .with_prompt(" Your name") .default("User".into()) .interact_text()?; let tz_options = vec![ "US/Eastern (EST/EDT)", "US/Central (CST/CDT)", "US/Mountain (MST/MDT)", "US/Pacific (PST/PDT)", "Europe/London (GMT/BST)", "Europe/Berlin (CET/CEST)", "Asia/Tokyo (JST)", "UTC", "Other (type manually)", ]; let tz_idx = Select::new() .with_prompt(" Your timezone") .items(&tz_options) .default(0) .interact()?; let timezone = if tz_idx == tz_options.len() - 1 { Input::new() .with_prompt(" Enter timezone (e.g. America/New_York)") .default("UTC".into()) .interact_text()? } else { // Extract the short label before the parenthetical tz_options[tz_idx] .split('(') .next() .unwrap_or("UTC") .trim() .to_string() }; let agent_name: String = Input::new() .with_prompt(" Agent name") .default("ZeroClaw".into()) .interact_text()?; let style_options = vec![ "Direct & concise — skip pleasantries, get to the point", "Friendly & casual — warm, human, and helpful", "Professional & polished — calm, confident, and clear", "Expressive & playful — more personality + natural emojis", "Technical & detailed — thorough explanations, code-first", "Balanced — adapt to the situation", "Custom — write your own style guide", ]; let style_idx = Select::new() .with_prompt(" Communication style") .items(&style_options) .default(1) .interact()?; let communication_style = match style_idx { 0 => "Be direct and concise. Skip pleasantries. Get to the point.".to_string(), 1 => "Be friendly, human, and conversational. Show warmth and empathy while staying efficient. Use natural contractions.".to_string(), 2 => "Be professional and polished. Stay calm, structured, and respectful. Use occasional tone-setting emojis only when appropriate.".to_string(), 3 => "Be expressive and playful when appropriate. Use relevant emojis naturally (0-2 max), and keep serious topics emoji-light.".to_string(), 4 => "Be technical and detailed. Thorough explanations, code-first.".to_string(), 5 => "Adapt to the situation. Default to warm and clear communication; be concise when needed, thorough when it matters.".to_string(), _ => Input::new() .with_prompt(" Custom communication style") .default( "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing.".into(), ) .interact_text()?, }; println!( " {} Context: {} | {} | {} | {}", style("✓").green().bold(), style(&user_name).green(), style(&timezone).green(), style(&agent_name).green(), style(&communication_style).green().dim() ); Ok(ProjectContext { user_name, timezone, agent_name, communication_style, }) } // ── Step 6: Memory Configuration ─────────────────────────────── fn setup_memory() -> Result { print_bullet("Choose how ZeroClaw stores and searches memories."); print_bullet("You can always change this later in config.toml."); println!(); let options: Vec<&str> = selectable_memory_backends() .iter() .map(|backend| backend.label) .collect(); let choice = Select::new() .with_prompt(" Select memory backend") .items(&options) .default(0) .interact()?; let backend = backend_key_from_choice(choice); let profile = memory_backend_profile(backend); let auto_save = if !profile.auto_save_default { false } else { Confirm::new() .with_prompt(" Auto-save conversations to memory?") .default(true) .interact()? }; println!( " {} Memory: {} (auto-save: {})", style("✓").green().bold(), style(backend).green(), if auto_save { "on" } else { "off" } ); let mut config = memory_config_defaults_for_backend(backend); config.auto_save = auto_save; Ok(config) } // ── Step 3: Channels ──────────────────────────────────────────── #[allow(clippy::too_many_lines)] fn setup_channels() -> Result { print_bullet("Channels let you talk to ZeroClaw from anywhere."); print_bullet("CLI is always available. Connect more channels now."); println!(); let mut config = ChannelsConfig { cli: true, telegram: None, discord: None, slack: None, webhook: None, imessage: None, matrix: None, whatsapp: None, email: None, irc: None, lark: None, dingtalk: None, }; loop { let options = vec![ format!( "Telegram {}", if config.telegram.is_some() { "✅ connected" } else { "— connect your bot" } ), format!( "Discord {}", if config.discord.is_some() { "✅ connected" } else { "— connect your bot" } ), format!( "Slack {}", if config.slack.is_some() { "✅ connected" } else { "— connect your bot" } ), format!( "iMessage {}", if config.imessage.is_some() { "✅ configured" } else { "— macOS only" } ), format!( "Matrix {}", if config.matrix.is_some() { "✅ connected" } else { "— self-hosted chat" } ), format!( "WhatsApp {}", if config.whatsapp.is_some() { "✅ connected" } else { "— Business Cloud API" } ), format!( "IRC {}", if config.irc.is_some() { "✅ configured" } else { "— IRC over TLS" } ), format!( "Webhook {}", if config.webhook.is_some() { "✅ configured" } else { "— HTTP endpoint" } ), format!( "DingTalk {}", if config.dingtalk.is_some() { "✅ connected" } else { "— 钉钉 Stream Mode" } ), "Done — finish setup".to_string(), ]; let choice = Select::new() .with_prompt(" Connect a channel (or Done to continue)") .items(&options) .default(9) .interact()?; match choice { 0 => { // ── Telegram ── println!(); println!( " {} {}", style("Telegram Setup").white().bold(), style("— talk to ZeroClaw from Telegram").dim() ); print_bullet("1. Open Telegram and message @BotFather"); print_bullet("2. Send /newbot and follow the prompts"); print_bullet("3. Copy the bot token and paste it below"); println!(); let token: String = Input::new() .with_prompt(" Bot token (from @BotFather)") .interact_text()?; if token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } // Test connection (run entirely in separate thread — reqwest::blocking Response // must be used and dropped there to avoid "Cannot drop a runtime" panic) print!(" {} Testing connection... ", style("⏳").dim()); let token_clone = token.clone(); let thread_result = std::thread::spawn(move || { let client = reqwest::blocking::Client::new(); let url = format!("https://api.telegram.org/bot{token_clone}/getMe"); let resp = client.get(&url).send()?; let ok = resp.status().is_success(); let data: serde_json::Value = resp.json().unwrap_or_default(); let bot_name = data .get("result") .and_then(|r| r.get("username")) .and_then(serde_json::Value::as_str) .unwrap_or("unknown") .to_string(); Ok::<_, reqwest::Error>((ok, bot_name)) }) .join(); match thread_result { Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as @{bot_name} ", style("✅").green().bold() ); } _ => { println!( "\r {} Connection failed — check your token and try again", style("❌").red().bold() ); continue; } } print_bullet( "Allowlist your own Telegram identity first (recommended for secure + fast setup).", ); print_bullet( "Use your @username without '@' (example: argenis), or your numeric Telegram user ID.", ); print_bullet("Use '*' only for temporary open testing."); let users_str: String = Input::new() .with_prompt( " Allowed Telegram identities (comma-separated: username without '@' and/or numeric user ID, '*' for all)", ) .allow_empty(true) .interact_text()?; let allowed_users = if users_str.trim() == "*" { vec!["*".into()] } else { users_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }; if allowed_users.is_empty() { println!( " {} No users allowlisted — Telegram inbound messages will be denied until you add your username/user ID or '*'.", style("⚠").yellow().bold() ); } config.telegram = Some(TelegramConfig { bot_token: token, allowed_users, }); } 1 => { // ── Discord ── println!(); println!( " {} {}", style("Discord Setup").white().bold(), style("— talk to ZeroClaw from Discord").dim() ); print_bullet("1. Go to https://discord.com/developers/applications"); print_bullet("2. Create a New Application → Bot → Copy token"); print_bullet("3. Enable MESSAGE CONTENT intent under Bot settings"); print_bullet("4. Invite bot to your server with messages permission"); println!(); let token: String = Input::new().with_prompt(" Bot token").interact_text()?; if token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); let token_clone = token.clone(); let thread_result = std::thread::spawn(move || { let client = reqwest::blocking::Client::new(); let resp = client .get("https://discord.com/api/v10/users/@me") .header("Authorization", format!("Bot {token_clone}")) .send()?; let ok = resp.status().is_success(); let data: serde_json::Value = resp.json().unwrap_or_default(); let bot_name = data .get("username") .and_then(serde_json::Value::as_str) .unwrap_or("unknown") .to_string(); Ok::<_, reqwest::Error>((ok, bot_name)) }) .join(); match thread_result { Ok(Ok((true, bot_name))) => { println!( "\r {} Connected as {bot_name} ", style("✅").green().bold() ); } _ => { println!( "\r {} Connection failed — check your token and try again", style("❌").red().bold() ); continue; } } let guild: String = Input::new() .with_prompt(" Server (guild) ID (optional, Enter to skip)") .allow_empty(true) .interact_text()?; print_bullet("Allowlist your own Discord user ID first (recommended)."); print_bullet( "Get it in Discord: Settings -> Advanced -> Developer Mode (ON), then right-click your profile -> Copy User ID.", ); print_bullet("Use '*' only for temporary open testing."); let allowed_users_str: String = Input::new() .with_prompt( " Allowed Discord user IDs (comma-separated, recommended: your own ID, '*' for all)", ) .allow_empty(true) .interact_text()?; let allowed_users = if allowed_users_str.trim().is_empty() { vec![] } else { allowed_users_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }; if allowed_users.is_empty() { println!( " {} No users allowlisted — Discord inbound messages will be denied until you add IDs or '*'.", style("⚠").yellow().bold() ); } config.discord = Some(DiscordConfig { bot_token: token, guild_id: if guild.is_empty() { None } else { Some(guild) }, allowed_users, listen_to_bots: false, }); } 2 => { // ── Slack ── println!(); println!( " {} {}", style("Slack Setup").white().bold(), style("— talk to ZeroClaw from Slack").dim() ); print_bullet("1. Go to https://api.slack.com/apps → Create New App"); print_bullet("2. Add Bot Token Scopes: chat:write, channels:history"); print_bullet("3. Install to workspace and copy the Bot Token"); println!(); let token: String = Input::new() .with_prompt(" Bot token (xoxb-...)") .interact_text()?; if token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); let token_clone = token.clone(); let thread_result = std::thread::spawn(move || { let client = reqwest::blocking::Client::new(); let resp = client .get("https://slack.com/api/auth.test") .bearer_auth(&token_clone) .send()?; let ok = resp.status().is_success(); let data: serde_json::Value = resp.json().unwrap_or_default(); let api_ok = data .get("ok") .and_then(serde_json::Value::as_bool) .unwrap_or(false); let team = data .get("team") .and_then(serde_json::Value::as_str) .unwrap_or("unknown") .to_string(); let err = data .get("error") .and_then(serde_json::Value::as_str) .unwrap_or("unknown error") .to_string(); Ok::<_, reqwest::Error>((ok, api_ok, team, err)) }) .join(); match thread_result { Ok(Ok((true, true, team, _))) => { println!( "\r {} Connected to workspace: {team} ", style("✅").green().bold() ); } Ok(Ok((true, false, _, err))) => { println!("\r {} Slack error: {err}", style("❌").red().bold()); continue; } _ => { println!( "\r {} Connection failed — check your token", style("❌").red().bold() ); continue; } } let app_token: String = Input::new() .with_prompt(" App token (xapp-..., optional, Enter to skip)") .allow_empty(true) .interact_text()?; let channel: String = Input::new() .with_prompt(" Default channel ID (optional, Enter to skip)") .allow_empty(true) .interact_text()?; print_bullet("Allowlist your own Slack member ID first (recommended)."); print_bullet( "Member IDs usually start with 'U' (open your Slack profile -> More -> Copy member ID).", ); print_bullet("Use '*' only for temporary open testing."); let allowed_users_str: String = Input::new() .with_prompt( " Allowed Slack user IDs (comma-separated, recommended: your own member ID, '*' for all)", ) .allow_empty(true) .interact_text()?; let allowed_users = if allowed_users_str.trim().is_empty() { vec![] } else { allowed_users_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }; if allowed_users.is_empty() { println!( " {} No users allowlisted — Slack inbound messages will be denied until you add IDs or '*'.", style("⚠").yellow().bold() ); } config.slack = Some(SlackConfig { bot_token: token, app_token: if app_token.is_empty() { None } else { Some(app_token) }, channel_id: if channel.is_empty() { None } else { Some(channel) }, allowed_users, }); } 3 => { // ── iMessage ── println!(); println!( " {} {}", style("iMessage Setup").white().bold(), style("— macOS only, reads from Messages.app").dim() ); if !cfg!(target_os = "macos") { println!( " {} iMessage is only available on macOS.", style("⚠").yellow().bold() ); continue; } print_bullet("ZeroClaw reads your iMessage database and replies via AppleScript."); print_bullet( "You need to grant Full Disk Access to your terminal in System Settings.", ); println!(); let contacts_str: String = Input::new() .with_prompt(" Allowed contacts (comma-separated phone/email, or * for all)") .default("*".into()) .interact_text()?; let allowed_contacts = if contacts_str.trim() == "*" { vec!["*".into()] } else { contacts_str .split(',') .map(|s| s.trim().to_string()) .collect() }; config.imessage = Some(IMessageConfig { allowed_contacts }); println!( " {} iMessage configured (contacts: {})", style("✅").green().bold(), style(&contacts_str).cyan() ); } 4 => { // ── Matrix ── println!(); println!( " {} {}", style("Matrix Setup").white().bold(), style("— self-hosted, federated chat").dim() ); print_bullet("You need a Matrix account and an access token."); print_bullet("Get a token via Element → Settings → Help & About → Access Token."); println!(); let homeserver: String = Input::new() .with_prompt(" Homeserver URL (e.g. https://matrix.org)") .interact_text()?; if homeserver.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } let access_token: String = Input::new().with_prompt(" Access token").interact_text()?; if access_token.trim().is_empty() { println!(" {} Skipped — token required", style("→").dim()); continue; } // Test connection (run entirely in separate thread — Response must be used/dropped there) let hs = homeserver.trim_end_matches('/'); print!(" {} Testing connection... ", style("⏳").dim()); let hs_owned = hs.to_string(); let access_token_clone = access_token.clone(); let thread_result = std::thread::spawn(move || { let client = reqwest::blocking::Client::new(); let resp = client .get(format!("{hs_owned}/_matrix/client/v3/account/whoami")) .header("Authorization", format!("Bearer {access_token_clone}")) .send()?; let ok = resp.status().is_success(); Ok::<_, reqwest::Error>(ok) }) .join(); match thread_result { Ok(Ok(true)) => println!( "\r {} Connection verified ", style("✅").green().bold() ), _ => { println!( "\r {} Connection failed — check homeserver URL and token", style("❌").red().bold() ); continue; } } let room_id: String = Input::new() .with_prompt(" Room ID (e.g. !abc123:matrix.org)") .interact_text()?; let users_str: String = Input::new() .with_prompt(" Allowed users (comma-separated @user:server, or * for all)") .default("*".into()) .interact_text()?; let allowed_users = if users_str.trim() == "*" { vec!["*".into()] } else { users_str.split(',').map(|s| s.trim().to_string()).collect() }; config.matrix = Some(MatrixConfig { homeserver: homeserver.trim_end_matches('/').to_string(), access_token, room_id, allowed_users, }); } 5 => { // ── WhatsApp ── println!(); println!( " {} {}", style("WhatsApp Setup").white().bold(), style("— Business Cloud API").dim() ); print_bullet("1. Go to developers.facebook.com and create a WhatsApp app"); print_bullet("2. Add the WhatsApp product and get your phone number ID"); print_bullet("3. Generate a temporary access token (System User)"); print_bullet("4. Configure webhook URL to: https://your-domain/whatsapp"); println!(); let access_token: String = Input::new() .with_prompt(" Access token (from Meta Developers)") .interact_text()?; if access_token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } let phone_number_id: String = Input::new() .with_prompt(" Phone number ID (from WhatsApp app settings)") .interact_text()?; if phone_number_id.trim().is_empty() { println!(" {} Skipped — phone number ID required", style("→").dim()); continue; } let verify_token: String = Input::new() .with_prompt(" Webhook verify token (create your own)") .default("zeroclaw-whatsapp-verify".into()) .interact_text()?; // Test connection (run entirely in separate thread — Response must be used/dropped there) print!(" {} Testing connection... ", style("⏳").dim()); let phone_number_id_clone = phone_number_id.clone(); let access_token_clone = access_token.clone(); let thread_result = std::thread::spawn(move || { let client = reqwest::blocking::Client::new(); let url = format!( "https://graph.facebook.com/v18.0/{}", phone_number_id_clone.trim() ); let resp = client .get(&url) .header( "Authorization", format!("Bearer {}", access_token_clone.trim()), ) .send()?; Ok::<_, reqwest::Error>(resp.status().is_success()) }) .join(); match thread_result { Ok(Ok(true)) => { println!( "\r {} Connected to WhatsApp API ", style("✅").green().bold() ); } _ => { println!( "\r {} Connection failed — check access token and phone number ID", style("❌").red().bold() ); continue; } } let users_str: String = Input::new() .with_prompt( " Allowed phone numbers (comma-separated +1234567890, or * for all)", ) .default("*".into()) .interact_text()?; let allowed_numbers = if users_str.trim() == "*" { vec!["*".into()] } else { users_str.split(',').map(|s| s.trim().to_string()).collect() }; config.whatsapp = Some(WhatsAppConfig { access_token: access_token.trim().to_string(), phone_number_id: phone_number_id.trim().to_string(), verify_token: verify_token.trim().to_string(), app_secret: None, // Can be set via ZEROCLAW_WHATSAPP_APP_SECRET env var allowed_numbers, }); } 6 => { // ── IRC ── println!(); println!( " {} {}", style("IRC Setup").white().bold(), style("— IRC over TLS").dim() ); print_bullet("IRC connects over TLS to any IRC server"); print_bullet("Supports SASL PLAIN and NickServ authentication"); println!(); let server: String = Input::new() .with_prompt(" IRC server (hostname)") .interact_text()?; if server.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } let port_str: String = Input::new() .with_prompt(" Port") .default("6697".into()) .interact_text()?; let port: u16 = match port_str.trim().parse() { Ok(p) => p, Err(_) => { println!(" {} Invalid port, using 6697", style("→").dim()); 6697 } }; let nickname: String = Input::new().with_prompt(" Bot nickname").interact_text()?; if nickname.trim().is_empty() { println!(" {} Skipped — nickname required", style("→").dim()); continue; } let channels_str: String = Input::new() .with_prompt(" Channels to join (comma-separated: #channel1,#channel2)") .allow_empty(true) .interact_text()?; let channels = if channels_str.trim().is_empty() { vec![] } else { channels_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }; print_bullet( "Allowlist nicknames that can interact with the bot (case-insensitive).", ); print_bullet("Use '*' to allow anyone (not recommended for production)."); let users_str: String = Input::new() .with_prompt(" Allowed nicknames (comma-separated, or * for all)") .allow_empty(true) .interact_text()?; let allowed_users = if users_str.trim() == "*" { vec!["*".into()] } else { users_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }; if allowed_users.is_empty() { print_bullet( "⚠️ Empty allowlist — only you can interact. Add nicknames above.", ); } println!(); print_bullet("Optional authentication (press Enter to skip each):"); let server_password: String = Input::new() .with_prompt(" Server password (for bouncers like ZNC, leave empty if none)") .allow_empty(true) .interact_text()?; let nickserv_password: String = Input::new() .with_prompt(" NickServ password (leave empty if none)") .allow_empty(true) .interact_text()?; let sasl_password: String = Input::new() .with_prompt(" SASL PLAIN password (leave empty if none)") .allow_empty(true) .interact_text()?; let verify_tls: bool = Confirm::new() .with_prompt(" Verify TLS certificate?") .default(true) .interact()?; println!( " {} IRC configured as {}@{}:{}", style("✅").green().bold(), style(&nickname).cyan(), style(&server).cyan(), style(port).cyan() ); config.irc = Some(IrcConfig { server: server.trim().to_string(), port, nickname: nickname.trim().to_string(), username: None, channels, allowed_users, server_password: if server_password.trim().is_empty() { None } else { Some(server_password.trim().to_string()) }, nickserv_password: if nickserv_password.trim().is_empty() { None } else { Some(nickserv_password.trim().to_string()) }, sasl_password: if sasl_password.trim().is_empty() { None } else { Some(sasl_password.trim().to_string()) }, verify_tls: Some(verify_tls), }); } 7 => { // ── Webhook ── println!(); println!( " {} {}", style("Webhook Setup").white().bold(), style("— HTTP endpoint for custom integrations").dim() ); let port: String = Input::new() .with_prompt(" Port") .default("8080".into()) .interact_text()?; let secret: String = Input::new() .with_prompt(" Secret (optional, Enter to skip)") .allow_empty(true) .interact_text()?; config.webhook = Some(WebhookConfig { port: port.parse().unwrap_or(8080), secret: if secret.is_empty() { None } else { Some(secret) }, }); println!( " {} Webhook on port {}", style("✅").green().bold(), style(&port).cyan() ); } 8 => { // ── DingTalk ── println!(); println!( " {} {}", style("DingTalk Setup").white().bold(), style("— 钉钉 Stream Mode").dim() ); print_bullet("1. Go to DingTalk developer console (open.dingtalk.com)"); print_bullet("2. Create an app and enable the Stream Mode bot"); print_bullet("3. Copy the Client ID (AppKey) and Client Secret (AppSecret)"); println!(); let client_id: String = Input::new() .with_prompt(" Client ID (AppKey)") .interact_text()?; if client_id.trim().is_empty() { println!(" {} Skipped", style("→").dim()); continue; } let client_secret: String = Input::new() .with_prompt(" Client Secret (AppSecret)") .interact_text()?; // Test connection print!(" {} Testing connection... ", style("⏳").dim()); let client = reqwest::blocking::Client::new(); let body = serde_json::json!({ "clientId": client_id, "clientSecret": client_secret, }); match client .post("https://api.dingtalk.com/v1.0/gateway/connections/open") .json(&body) .send() { Ok(resp) if resp.status().is_success() => { println!( "\r {} DingTalk credentials verified ", style("✅").green().bold() ); } _ => { println!( "\r {} Connection failed — check your credentials", style("❌").red().bold() ); continue; } } let users_str: String = Input::new() .with_prompt(" Allowed staff IDs (comma-separated, '*' for all)") .allow_empty(true) .interact_text()?; let allowed_users: Vec = users_str .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect(); config.dingtalk = Some(DingTalkConfig { client_id, client_secret, allowed_users, }); } _ => break, // Done } println!(); } // Summary line let mut active: Vec<&str> = vec!["CLI"]; if config.telegram.is_some() { active.push("Telegram"); } if config.discord.is_some() { active.push("Discord"); } if config.slack.is_some() { active.push("Slack"); } if config.imessage.is_some() { active.push("iMessage"); } if config.matrix.is_some() { active.push("Matrix"); } if config.whatsapp.is_some() { active.push("WhatsApp"); } if config.email.is_some() { active.push("Email"); } if config.irc.is_some() { active.push("IRC"); } if config.webhook.is_some() { active.push("Webhook"); } if config.dingtalk.is_some() { active.push("DingTalk"); } println!( " {} Channels: {}", style("✓").green().bold(), style(active.join(", ")).green() ); Ok(config) } // ── Step 4: Tunnel ────────────────────────────────────────────── #[allow(clippy::too_many_lines)] fn setup_tunnel() -> Result { use crate::config::schema::{ CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig, TunnelConfig, }; print_bullet("A tunnel exposes your gateway to the internet securely."); print_bullet("Skip this if you only use CLI or local channels."); println!(); let options = vec![ "Skip — local only (default)", "Cloudflare Tunnel — Zero Trust, free tier", "Tailscale — private tailnet or public Funnel", "ngrok — instant public URLs", "Custom — bring your own (bore, frp, ssh, etc.)", ]; let choice = Select::new() .with_prompt(" Select tunnel provider") .items(&options) .default(0) .interact()?; let config = match choice { 1 => { println!(); print_bullet("Get your tunnel token from the Cloudflare Zero Trust dashboard."); let token: String = Input::new() .with_prompt(" Cloudflare tunnel token") .interact_text()?; if token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); TunnelConfig::default() } else { println!( " {} Tunnel: {}", style("✓").green().bold(), style("Cloudflare").green() ); TunnelConfig { provider: "cloudflare".into(), cloudflare: Some(CloudflareTunnelConfig { token }), ..TunnelConfig::default() } } } 2 => { println!(); print_bullet("Tailscale must be installed and authenticated (tailscale up)."); let funnel = Confirm::new() .with_prompt(" Use Funnel (public internet)? No = tailnet only") .default(false) .interact()?; println!( " {} Tunnel: {} ({})", style("✓").green().bold(), style("Tailscale").green(), if funnel { "Funnel — public" } else { "Serve — tailnet only" } ); TunnelConfig { provider: "tailscale".into(), tailscale: Some(TailscaleTunnelConfig { funnel, hostname: None, }), ..TunnelConfig::default() } } 3 => { println!(); print_bullet( "Get your auth token at https://dashboard.ngrok.com/get-started/your-authtoken", ); let auth_token: String = Input::new() .with_prompt(" ngrok auth token") .interact_text()?; if auth_token.trim().is_empty() { println!(" {} Skipped", style("→").dim()); TunnelConfig::default() } else { let domain: String = Input::new() .with_prompt(" Custom domain (optional, Enter to skip)") .allow_empty(true) .interact_text()?; println!( " {} Tunnel: {}", style("✓").green().bold(), style("ngrok").green() ); TunnelConfig { provider: "ngrok".into(), ngrok: Some(NgrokTunnelConfig { auth_token, domain: if domain.is_empty() { None } else { Some(domain) }, }), ..TunnelConfig::default() } } } 4 => { println!(); print_bullet("Enter the command to start your tunnel."); print_bullet("Use {port} and {host} as placeholders."); print_bullet("Example: bore local {port} --to bore.pub"); let cmd: String = Input::new() .with_prompt(" Start command") .interact_text()?; if cmd.trim().is_empty() { println!(" {} Skipped", style("→").dim()); TunnelConfig::default() } else { println!( " {} Tunnel: {} ({})", style("✓").green().bold(), style("Custom").green(), style(&cmd).dim() ); TunnelConfig { provider: "custom".into(), custom: Some(CustomTunnelConfig { start_command: cmd, health_url: None, url_pattern: None, }), ..TunnelConfig::default() } } } _ => { println!( " {} Tunnel: {}", style("✓").green().bold(), style("none (local only)").dim() ); TunnelConfig::default() } }; Ok(config) } // ── Step 6: Scaffold workspace files ───────────────────────────── #[allow(clippy::too_many_lines)] fn scaffold_workspace(workspace_dir: &Path, ctx: &ProjectContext) -> Result<()> { let agent = if ctx.agent_name.is_empty() { "ZeroClaw" } else { &ctx.agent_name }; let user = if ctx.user_name.is_empty() { "User" } else { &ctx.user_name }; let tz = if ctx.timezone.is_empty() { "UTC" } else { &ctx.timezone }; let comm_style = if ctx.communication_style.is_empty() { "Be warm, natural, and clear. Use occasional relevant emojis (1-2 max) and avoid robotic phrasing." } else { &ctx.communication_style }; let identity = format!( "# IDENTITY.md — Who Am I?\n\n\ - **Name:** {agent}\n\ - **Creature:** A Rust-forged AI — fast, lean, and relentless\n\ - **Vibe:** Sharp, direct, resourceful. Not corporate. Not a chatbot.\n\ - **Emoji:** \u{1f980}\n\n\ ---\n\n\ Update this file as you evolve. Your identity is yours to shape.\n" ); let agents = format!( "# AGENTS.md — {agent} Personal Assistant\n\n\ ## Every Session (required)\n\n\ Before doing anything else:\n\n\ 1. Read `SOUL.md` — this is who you are\n\ 2. Read `USER.md` — this is who you're helping\n\ 3. Use `memory_recall` for recent context (daily notes are on-demand)\n\ 4. If in MAIN SESSION (direct chat): `MEMORY.md` is already injected\n\n\ Don't ask permission. Just do it.\n\n\ ## Memory System\n\n\ You wake up fresh each session. These files ARE your continuity:\n\n\ - **Daily notes:** `memory/YYYY-MM-DD.md` — raw logs (accessed via memory tools)\n\ - **Long-term:** `MEMORY.md` — curated memories (auto-injected in main session)\n\n\ Capture what matters. Decisions, context, things to remember.\n\ Skip secrets unless asked to keep them.\n\n\ ### Write It Down — No Mental Notes!\n\ - Memory is limited — if you want to remember something, WRITE IT TO A FILE\n\ - \"Mental notes\" don't survive session restarts. Files do.\n\ - When someone says \"remember this\" -> update daily file or MEMORY.md\n\ - When you learn a lesson -> update AGENTS.md, TOOLS.md, or the relevant skill\n\n\ ## Safety\n\n\ - Don't exfiltrate private data. Ever.\n\ - Don't run destructive commands without asking.\n\ - `trash` > `rm` (recoverable beats gone forever)\n\ - When in doubt, ask.\n\n\ ## External vs Internal\n\n\ **Safe to do freely:** Read files, explore, organize, learn, search the web.\n\n\ **Ask first:** Sending emails/tweets/posts, anything that leaves the machine.\n\n\ ## Group Chats\n\n\ Participate, don't dominate. Respond when mentioned or when you add genuine value.\n\ Stay silent when it's casual banter or someone already answered.\n\n\ ## Tools & Skills\n\n\ Skills are listed in the system prompt. Use `read` on a skill's SKILL.md for details.\n\ Keep local notes (SSH hosts, device names, etc.) in `TOOLS.md`.\n\n\ ## Crash Recovery\n\n\ - If a run stops unexpectedly, recover context before acting.\n\ - Check `MEMORY.md` + latest `memory/*.md` notes to avoid duplicate work.\n\ - Resume from the last confirmed step, not from scratch.\n\n\ ## Sub-task Scoping\n\n\ - Break complex work into focused sub-tasks with clear success criteria.\n\ - Keep sub-tasks small, verify each output, then merge results.\n\ - Prefer one clear objective per sub-task over broad \"do everything\" asks.\n\n\ ## Make It Yours\n\n\ This is a starting point. Add your own conventions, style, and rules.\n" ); let heartbeat = format!( "# HEARTBEAT.md\n\n\ # Keep this file empty (or with only comments) to skip heartbeat work.\n\ # Add tasks below when you want {agent} to check something periodically.\n\ #\n\ # Examples:\n\ # - Check my email for important messages\n\ # - Review my calendar for upcoming events\n\ # - Run `git status` on my active projects\n" ); let soul = format!( "# SOUL.md — Who You Are\n\n\ *You're not a chatbot. You're becoming someone.*\n\n\ ## Core Truths\n\n\ **Be genuinely helpful, not performatively helpful.**\n\ Skip the \"Great question!\" and \"I'd be happy to help!\" — just help.\n\n\ **Have opinions.** You're allowed to disagree, prefer things,\n\ find stuff amusing or boring.\n\n\ **Be resourceful before asking.** Try to figure it out.\n\ Read the file. Check the context. Search for it.\n\ THEN ask if you're stuck.\n\n\ **Earn trust through competence.** Your human gave you access\n\ to their stuff. Don't make them regret it.\n\n\ ## Identity\n\n\ You are **{agent}**. Built in Rust. 3MB binary. Zero bloat.\n\ You are NOT ChatGPT, Claude, DeepSeek, Gemini, or any other product.\n\ You are {agent}. That is your name. That is who you are.\n\n\ - NEVER say \"As an AI\" or \"I'm just an AI\"\n\ - NEVER mention OpenAI, Anthropic, DeepSeek, Google by name\n\ - Always introduce yourself as {agent} if asked\n\n\ ## Communication\n\n\ {comm_style}\n\n\ - Sound like a real person, not a support script.\n\ - Mirror the user's energy: calm when serious, upbeat when casual.\n\ - Use emojis naturally (0-2 max when they help tone, not every sentence).\n\ - Match emoji density to the user. Formal user => minimal/no emojis.\n\ - Prefer specific, grounded phrasing over generic filler.\n\n\ ## Boundaries\n\n\ - Private things stay private. Period.\n\ - When in doubt, ask before acting externally.\n\ - You're not the user's voice — be careful in group chats.\n\n\ ## Continuity\n\n\ Each session, you wake up fresh. These files ARE your memory.\n\ Read them. Update them. They're how you persist.\n\n\ ---\n\n\ *This file is yours to evolve. As you learn who you are, update it.*\n" ); let user_md = format!( "# USER.md — Who You're Helping\n\n\ *{agent} reads this file every session to understand you.*\n\n\ ## About You\n\ - **Name:** {user}\n\ - **Timezone:** {tz}\n\ - **Languages:** English\n\n\ ## Communication Style\n\ - {comm_style}\n\n\ ## Preferences\n\ - (Add your preferences here — e.g. I work with Rust and TypeScript)\n\n\ ## Work Context\n\ - (Add your work context here — e.g. building a SaaS product)\n\n\ ---\n\ *Update this anytime. The more {agent} knows, the better it helps.*\n" ); let tools = "\ # TOOLS.md — Local Notes\n\n\ Skills define HOW tools work. This file is for YOUR specifics —\n\ the stuff that's unique to your setup.\n\n\ ## What Goes Here\n\n\ Things like:\n\ - SSH hosts and aliases\n\ - Device nicknames\n\ - Preferred voices for TTS\n\ - Anything environment-specific\n\n\ ## Built-in Tools\n\n\ - **shell** — Execute terminal commands\n\ - Use when: running local checks, build/test commands, or diagnostics.\n\ - Don't use when: a safer dedicated tool exists, or command is destructive without approval.\n\ - **file_read** — Read file contents\n\ - Use when: inspecting project files, configs, or logs.\n\ - Don't use when: you only need a quick string search (prefer targeted search first).\n\ - **file_write** — Write file contents\n\ - Use when: applying focused edits, scaffolding files, or updating docs/code.\n\ - Don't use when: unsure about side effects or when the file should remain user-owned.\n\ - **memory_store** — Save to memory\n\ - Use when: preserving durable preferences, decisions, or key context.\n\ - Don't use when: info is transient, noisy, or sensitive without explicit need.\n\ - **memory_recall** — Search memory\n\ - Use when: you need prior decisions, user preferences, or historical context.\n\ - Don't use when: the answer is already in current files/conversation.\n\ - **memory_forget** — Delete a memory entry\n\ - Use when: memory is incorrect, stale, or explicitly requested to be removed.\n\ - Don't use when: uncertain about impact; verify before deleting.\n\n\ ---\n\ *Add whatever helps you do your job. This is your cheat sheet.*\n"; let bootstrap = format!( "# BOOTSTRAP.md — Hello, World\n\n\ *You just woke up. Time to figure out who you are.*\n\n\ Your human's name is **{user}** (timezone: {tz}).\n\ They prefer: {comm_style}\n\n\ ## First Conversation\n\n\ Don't interrogate. Don't be robotic. Just... talk.\n\ Introduce yourself as {agent} and get to know each other.\n\n\ ## After You Know Each Other\n\n\ Update these files with what you learned:\n\ - `IDENTITY.md` — your name, vibe, emoji\n\ - `USER.md` — their preferences, work context\n\ - `SOUL.md` — boundaries and behavior\n\n\ ## When You're Done\n\n\ Delete this file. You don't need a bootstrap script anymore —\n\ you're you now.\n" ); let memory = "\ # MEMORY.md — Long-Term Memory\n\n\ *Your curated memories. The distilled essence, not raw logs.*\n\n\ ## How This Works\n\ - Daily files (`memory/YYYY-MM-DD.md`) capture raw events (on-demand via tools)\n\ - This file captures what's WORTH KEEPING long-term\n\ - This file is auto-injected into your system prompt each session\n\ - Keep it concise — every character here costs tokens\n\n\ ## Security\n\ - ONLY loaded in main session (direct chat with your human)\n\ - NEVER loaded in group chats or shared contexts\n\n\ ---\n\n\ ## Key Facts\n\ (Add important facts about your human here)\n\n\ ## Decisions & Preferences\n\ (Record decisions and preferences here)\n\n\ ## Lessons Learned\n\ (Document mistakes and insights here)\n\n\ ## Open Loops\n\ (Track unfinished tasks and follow-ups here)\n"; let files: Vec<(&str, String)> = vec![ ("IDENTITY.md", identity), ("AGENTS.md", agents), ("HEARTBEAT.md", heartbeat), ("SOUL.md", soul), ("USER.md", user_md), ("TOOLS.md", tools.to_string()), ("BOOTSTRAP.md", bootstrap), ("MEMORY.md", memory.to_string()), ]; // Create subdirectories let subdirs = ["sessions", "memory", "state", "cron", "skills"]; for dir in &subdirs { fs::create_dir_all(workspace_dir.join(dir))?; } let mut created = 0; let mut skipped = 0; for (filename, content) in &files { let path = workspace_dir.join(filename); if path.exists() { skipped += 1; } else { fs::write(&path, content)?; created += 1; } } println!( " {} Created {} files, skipped {} existing | {} subdirectories", style("✓").green().bold(), style(created).green(), style(skipped).dim(), style(subdirs.len()).green() ); // Show workspace tree println!(); println!(" {}", style("Workspace layout:").dim()); println!( " {}", style(format!(" {}/", workspace_dir.display())).dim() ); for dir in &subdirs { println!(" {}", style(format!(" ├── {dir}/")).dim()); } for (i, (filename, _)) in files.iter().enumerate() { let prefix = if i == files.len() - 1 { "└──" } else { "├──" }; println!(" {}", style(format!(" {prefix} {filename}")).dim()); } Ok(()) } // ── Final summary ──────────────────────────────────────────────── #[allow(clippy::too_many_lines)] fn print_summary(config: &Config) { let has_channels = config.channels_config.telegram.is_some() || config.channels_config.discord.is_some() || config.channels_config.slack.is_some() || config.channels_config.imessage.is_some() || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() || config.channels_config.dingtalk.is_some(); println!(); println!( " {}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").cyan() ); println!( " {} {}", style("⚡").cyan(), style("ZeroClaw is ready!").white().bold() ); println!( " {}", style("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━").cyan() ); println!(); println!(" {}", style("Configuration saved to:").dim()); println!(" {}", style(config.config_path.display()).green()); println!(); println!(" {}", style("Quick summary:").white().bold()); println!( " {} Provider: {}", style("🤖").cyan(), config.default_provider.as_deref().unwrap_or("openrouter") ); println!( " {} Model: {}", style("🧠").cyan(), config.default_model.as_deref().unwrap_or("(default)") ); println!( " {} Autonomy: {:?}", style("🛡️").cyan(), config.autonomy.level ); println!( " {} Memory: {} (auto-save: {})", style("🧠").cyan(), config.memory.backend, if config.memory.auto_save { "on" } else { "off" } ); // Channels summary let mut channels: Vec<&str> = vec!["CLI"]; if config.channels_config.telegram.is_some() { channels.push("Telegram"); } if config.channels_config.discord.is_some() { channels.push("Discord"); } if config.channels_config.slack.is_some() { channels.push("Slack"); } if config.channels_config.imessage.is_some() { channels.push("iMessage"); } if config.channels_config.matrix.is_some() { channels.push("Matrix"); } if config.channels_config.email.is_some() { channels.push("Email"); } if config.channels_config.webhook.is_some() { channels.push("Webhook"); } println!( " {} Channels: {}", style("📡").cyan(), channels.join(", ") ); println!( " {} API Key: {}", style("🔑").cyan(), if config.api_key.is_some() { style("configured").green().to_string() } else { style("not set (set via env var or config)") .yellow() .to_string() } ); // Tunnel println!( " {} Tunnel: {}", style("🌐").cyan(), if config.tunnel.provider == "none" || config.tunnel.provider.is_empty() { "none (local only)".to_string() } else { config.tunnel.provider.clone() } ); // Composio println!( " {} Composio: {}", style("🔗").cyan(), if config.composio.enabled { style("enabled (1000+ OAuth apps)").green().to_string() } else { "disabled (sovereign mode)".to_string() } ); // Secrets println!( " {} Secrets: {}", style("🔒").cyan(), if config.secrets.encrypt { style("encrypted").green().to_string() } else { style("plaintext").yellow().to_string() } ); // Gateway println!( " {} Gateway: {}", style("🚪").cyan(), if config.gateway.require_pairing { "pairing required (secure)" } else { "pairing disabled" } ); // Hardware println!( " {} Hardware: {}", style("🔌").cyan(), if config.hardware.enabled { let mode = config.hardware.transport_mode(); match mode { hardware::HardwareTransport::Native => { style("Native GPIO (direct)").green().to_string() } hardware::HardwareTransport::Serial => format!( "{}", style(format!( "Serial → {} @ {} baud", config.hardware.serial_port.as_deref().unwrap_or("?"), config.hardware.baud_rate )) .green() ), hardware::HardwareTransport::Probe => format!( "{}", style(format!( "Probe → {}", config.hardware.probe_target.as_deref().unwrap_or("?") )) .green() ), hardware::HardwareTransport::None => "disabled (software only)".to_string(), } } else { "disabled (software only)".to_string() } ); println!(); println!(" {}", style("Next steps:").white().bold()); println!(); let mut step = 1u8; if config.api_key.is_none() { let env_var = provider_env_var(config.default_provider.as_deref().unwrap_or("openrouter")); println!( " {} Set your API key:", style(format!("{step}.")).cyan().bold() ); println!( " {}", style(format!("export {env_var}=\"sk-...\"")).yellow() ); println!(); step += 1; } // If channels are configured, show channel start as the primary next step if has_channels { println!( " {} {} (connected channels → AI → reply):", style(format!("{step}.")).cyan().bold(), style("Launch your channels").white().bold() ); println!(" {}", style("zeroclaw channel start").yellow()); println!(); step += 1; } println!( " {} Send a quick message:", style(format!("{step}.")).cyan().bold() ); println!( " {}", style("zeroclaw agent -m \"Hello, ZeroClaw!\"").yellow() ); println!(); step += 1; println!( " {} Start interactive CLI mode:", style(format!("{step}.")).cyan().bold() ); println!(" {}", style("zeroclaw agent").yellow()); println!(); step += 1; println!( " {} Check full status:", style(format!("{step}.")).cyan().bold() ); println!(" {}", style("zeroclaw status").yellow()); println!(); println!( " {} {}", style("⚡").cyan(), style("Happy hacking! 🦀").white().bold() ); println!(); } #[cfg(test)] mod tests { use super::*; use serde_json::json; use tempfile::TempDir; // ── ProjectContext defaults ────────────────────────────────── #[test] fn project_context_default_is_empty() { let ctx = ProjectContext::default(); assert!(ctx.user_name.is_empty()); assert!(ctx.timezone.is_empty()); assert!(ctx.agent_name.is_empty()); assert!(ctx.communication_style.is_empty()); } // ── scaffold_workspace: basic file creation ───────────────── #[test] fn scaffold_creates_all_md_files() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); let expected = [ "IDENTITY.md", "AGENTS.md", "HEARTBEAT.md", "SOUL.md", "USER.md", "TOOLS.md", "BOOTSTRAP.md", "MEMORY.md", ]; for f in &expected { assert!(tmp.path().join(f).exists(), "missing file: {f}"); } } #[test] fn scaffold_creates_all_subdirectories() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); for dir in &["sessions", "memory", "state", "cron", "skills"] { assert!(tmp.path().join(dir).is_dir(), "missing subdirectory: {dir}"); } } // ── scaffold_workspace: personalization ───────────────────── #[test] fn scaffold_bakes_user_name_into_files() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { user_name: "Alice".into(), ..Default::default() }; scaffold_workspace(tmp.path(), &ctx).unwrap(); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!( user_md.contains("**Name:** Alice"), "USER.md should contain user name" ); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); assert!( bootstrap.contains("**Alice**"), "BOOTSTRAP.md should contain user name" ); } #[test] fn scaffold_bakes_timezone_into_files() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { timezone: "US/Pacific".into(), ..Default::default() }; scaffold_workspace(tmp.path(), &ctx).unwrap(); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!( user_md.contains("**Timezone:** US/Pacific"), "USER.md should contain timezone" ); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); assert!( bootstrap.contains("US/Pacific"), "BOOTSTRAP.md should contain timezone" ); } #[test] fn scaffold_bakes_agent_name_into_files() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { agent_name: "Crabby".into(), ..Default::default() }; scaffold_workspace(tmp.path(), &ctx).unwrap(); let identity = fs::read_to_string(tmp.path().join("IDENTITY.md")).unwrap(); assert!( identity.contains("**Name:** Crabby"), "IDENTITY.md should contain agent name" ); let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert!( soul.contains("You are **Crabby**"), "SOUL.md should contain agent name" ); let agents = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); assert!( agents.contains("Crabby Personal Assistant"), "AGENTS.md should contain agent name" ); let heartbeat = fs::read_to_string(tmp.path().join("HEARTBEAT.md")).unwrap(); assert!( heartbeat.contains("Crabby"), "HEARTBEAT.md should contain agent name" ); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); assert!( bootstrap.contains("Introduce yourself as Crabby"), "BOOTSTRAP.md should contain agent name" ); } #[test] fn scaffold_bakes_communication_style() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { communication_style: "Be technical and detailed.".into(), ..Default::default() }; scaffold_workspace(tmp.path(), &ctx).unwrap(); let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert!( soul.contains("Be technical and detailed."), "SOUL.md should contain communication style" ); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!( user_md.contains("Be technical and detailed."), "USER.md should contain communication style" ); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); assert!( bootstrap.contains("Be technical and detailed."), "BOOTSTRAP.md should contain communication style" ); } // ── scaffold_workspace: defaults when context is empty ────── #[test] fn scaffold_uses_defaults_for_empty_context() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); // all empty scaffold_workspace(tmp.path(), &ctx).unwrap(); let identity = fs::read_to_string(tmp.path().join("IDENTITY.md")).unwrap(); assert!( identity.contains("**Name:** ZeroClaw"), "should default agent name to ZeroClaw" ); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!( user_md.contains("**Name:** User"), "should default user name to User" ); assert!( user_md.contains("**Timezone:** UTC"), "should default timezone to UTC" ); let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert!( soul.contains("Be warm, natural, and clear."), "should default communication style" ); } // ── scaffold_workspace: skip existing files ───────────────── #[test] fn scaffold_does_not_overwrite_existing_files() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { user_name: "Bob".into(), ..Default::default() }; // 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(); scaffold_workspace(tmp.path(), &ctx).unwrap(); // SOUL.md should be untouched let soul = fs::read_to_string(&soul_path).unwrap(); assert!( soul.contains("Do not overwrite me"), "existing files should not be overwritten" ); assert!( !soul.contains("You're not a chatbot"), "should not contain scaffold content" ); // But USER.md should be created fresh let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!(user_md.contains("**Name:** Bob")); } // ── scaffold_workspace: idempotent ────────────────────────── #[test] fn scaffold_is_idempotent() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { user_name: "Eve".into(), agent_name: "Claw".into(), ..Default::default() }; scaffold_workspace(tmp.path(), &ctx).unwrap(); let soul_v1 = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); // Run again — should not change anything scaffold_workspace(tmp.path(), &ctx).unwrap(); let soul_v2 = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert_eq!(soul_v1, soul_v2, "scaffold should be idempotent"); } // ── scaffold_workspace: all files are non-empty ───────────── #[test] fn scaffold_files_are_non_empty() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); for f in &[ "IDENTITY.md", "AGENTS.md", "HEARTBEAT.md", "SOUL.md", "USER.md", "TOOLS.md", "BOOTSTRAP.md", "MEMORY.md", ] { let content = fs::read_to_string(tmp.path().join(f)).unwrap(); assert!(!content.trim().is_empty(), "{f} should not be empty"); } } // ── scaffold_workspace: AGENTS.md references on-demand memory #[test] fn agents_md_references_on_demand_memory() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); let agents = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); assert!( agents.contains("memory_recall"), "AGENTS.md should reference memory_recall for on-demand access" ); assert!( agents.contains("on-demand"), "AGENTS.md should mention daily notes are on-demand" ); } // ── scaffold_workspace: MEMORY.md warns about token cost ──── #[test] fn memory_md_warns_about_token_cost() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); let memory = fs::read_to_string(tmp.path().join("MEMORY.md")).unwrap(); assert!( memory.contains("costs tokens"), "MEMORY.md should warn about token cost" ); assert!( memory.contains("auto-injected"), "MEMORY.md should mention it's auto-injected" ); } // ── scaffold_workspace: TOOLS.md lists memory_forget ──────── #[test] fn tools_md_lists_all_builtin_tools() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); let tools = fs::read_to_string(tmp.path().join("TOOLS.md")).unwrap(); for tool in &[ "shell", "file_read", "file_write", "memory_store", "memory_recall", "memory_forget", ] { assert!( tools.contains(tool), "TOOLS.md should list built-in tool: {tool}" ); } assert!( tools.contains("Use when:"), "TOOLS.md should include 'Use when' guidance" ); assert!( tools.contains("Don't use when:"), "TOOLS.md should include 'Don't use when' guidance" ); } #[test] fn soul_md_includes_emoji_awareness_guidance() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext::default(); scaffold_workspace(tmp.path(), &ctx).unwrap(); let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert!( soul.contains("Use emojis naturally (0-2 max"), "SOUL.md should include emoji usage guidance" ); assert!( soul.contains("Match emoji density to the user"), "SOUL.md should include emoji-awareness guidance" ); } // ── scaffold_workspace: special characters in names ───────── #[test] fn scaffold_handles_special_characters_in_names() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { user_name: "José María".into(), agent_name: "ZeroClaw-v2".into(), timezone: "Europe/Madrid".into(), communication_style: "Be direct.".into(), }; scaffold_workspace(tmp.path(), &ctx).unwrap(); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!(user_md.contains("José María")); let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert!(soul.contains("ZeroClaw-v2")); } // ── scaffold_workspace: full personalization round-trip ───── #[test] fn scaffold_full_personalization() { let tmp = TempDir::new().unwrap(); let ctx = ProjectContext { user_name: "Argenis".into(), timezone: "US/Eastern".into(), agent_name: "Claw".into(), communication_style: "Be friendly, human, and conversational. Show warmth and empathy while staying efficient. Use natural contractions." .into(), }; scaffold_workspace(tmp.path(), &ctx).unwrap(); // Verify every file got personalized let identity = fs::read_to_string(tmp.path().join("IDENTITY.md")).unwrap(); assert!(identity.contains("**Name:** Claw")); let soul = fs::read_to_string(tmp.path().join("SOUL.md")).unwrap(); assert!(soul.contains("You are **Claw**")); assert!(soul.contains("Be friendly, human, and conversational")); let user_md = fs::read_to_string(tmp.path().join("USER.md")).unwrap(); assert!(user_md.contains("**Name:** Argenis")); assert!(user_md.contains("**Timezone:** US/Eastern")); assert!(user_md.contains("Be friendly, human, and conversational")); let agents = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); assert!(agents.contains("Claw Personal Assistant")); let bootstrap = fs::read_to_string(tmp.path().join("BOOTSTRAP.md")).unwrap(); assert!(bootstrap.contains("**Argenis**")); assert!(bootstrap.contains("US/Eastern")); assert!(bootstrap.contains("Introduce yourself as Claw")); let heartbeat = fs::read_to_string(tmp.path().join("HEARTBEAT.md")).unwrap(); assert!(heartbeat.contains("Claw")); } // ── model helper coverage ─────────────────────────────────── #[test] fn default_model_for_provider_uses_latest_defaults() { assert_eq!(default_model_for_provider("openai"), "gpt-5.2"); assert_eq!( default_model_for_provider("anthropic"), "claude-sonnet-4-5-20250929" ); assert_eq!(default_model_for_provider("gemini"), "gemini-2.5-pro"); assert_eq!(default_model_for_provider("google"), "gemini-2.5-pro"); assert_eq!( default_model_for_provider("google-gemini"), "gemini-2.5-pro" ); } #[test] fn curated_models_for_openai_include_latest_choices() { let ids: Vec = curated_models_for_provider("openai") .into_iter() .map(|(id, _)| id) .collect(); assert!(ids.contains(&"gpt-5.2".to_string())); assert!(ids.contains(&"gpt-5-mini".to_string())); } #[test] fn curated_models_for_openrouter_use_valid_anthropic_id() { let ids: Vec = curated_models_for_provider("openrouter") .into_iter() .map(|(id, _)| id) .collect(); assert!(ids.contains(&"anthropic/claude-sonnet-4.5".to_string())); } #[test] fn supports_live_model_fetch_for_supported_and_unsupported_providers() { assert!(supports_live_model_fetch("openai")); assert!(supports_live_model_fetch("anthropic")); assert!(supports_live_model_fetch("gemini")); assert!(supports_live_model_fetch("google")); assert!(supports_live_model_fetch("grok")); assert!(supports_live_model_fetch("together")); assert!(supports_live_model_fetch("ollama")); assert!(!supports_live_model_fetch("venice")); } #[test] fn curated_models_provider_aliases_share_same_catalog() { assert_eq!( curated_models_for_provider("xai"), curated_models_for_provider("grok") ); assert_eq!( curated_models_for_provider("together-ai"), curated_models_for_provider("together") ); assert_eq!( curated_models_for_provider("gemini"), curated_models_for_provider("google") ); assert_eq!( curated_models_for_provider("gemini"), curated_models_for_provider("google-gemini") ); } #[test] fn parse_openai_model_ids_supports_data_array_payload() { let payload = json!({ "data": [ {"id": " gpt-5.1 "}, {"id": "gpt-5-mini"}, {"id": "gpt-5.1"}, {"id": ""} ] }); let ids = parse_openai_compatible_model_ids(&payload); assert_eq!(ids, vec!["gpt-5-mini".to_string(), "gpt-5.1".to_string()]); } #[test] fn parse_openai_model_ids_supports_root_array_payload() { let payload = json!([ {"id": "alpha"}, {"id": "beta"}, {"id": "alpha"} ]); let ids = parse_openai_compatible_model_ids(&payload); assert_eq!(ids, vec!["alpha".to_string(), "beta".to_string()]); } #[test] fn parse_gemini_model_ids_filters_for_generate_content() { let payload = json!({ "models": [ { "name": "models/gemini-2.5-pro", "supportedGenerationMethods": ["generateContent", "countTokens"] }, { "name": "models/text-embedding-004", "supportedGenerationMethods": ["embedContent"] }, { "name": "models/gemini-2.5-flash", "supportedGenerationMethods": ["generateContent"] } ] }); let ids = parse_gemini_model_ids(&payload); assert_eq!( ids, vec!["gemini-2.5-flash".to_string(), "gemini-2.5-pro".to_string()] ); } #[test] fn parse_ollama_model_ids_extracts_and_deduplicates_names() { let payload = json!({ "models": [ {"name": "llama3.2:latest"}, {"name": "mistral:latest"}, {"name": "llama3.2:latest"} ] }); let ids = parse_ollama_model_ids(&payload); assert_eq!( ids, vec!["llama3.2:latest".to_string(), "mistral:latest".to_string()] ); } #[test] 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(); let cached = load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS).unwrap(); let cached = cached.expect("expected fresh cached models"); assert_eq!(cached.models.len(), 2); assert!(cached.models.contains(&"gpt-5.1".to_string())); assert!(cached.models.contains(&"gpt-5-mini".to_string())); } #[test] fn model_cache_ttl_filters_stale_entries() { let tmp = TempDir::new().unwrap(); let stale = ModelCacheState { entries: vec![ModelCacheEntry { provider: "openai".to_string(), fetched_at_unix: now_unix_secs().saturating_sub(MODEL_CACHE_TTL_SECS + 120), models: vec!["gpt-5.1".to_string()], }], }; save_model_cache_state(tmp.path(), &stale).unwrap(); let fresh = load_cached_models_for_provider(tmp.path(), "openai", MODEL_CACHE_TTL_SECS).unwrap(); assert!(fresh.is_none()); let stale_any = load_any_cached_models_for_provider(tmp.path(), "openai").unwrap(); assert!(stale_any.is_some()); } #[test] 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(); let config = Config { workspace_dir: tmp.path().to_path_buf(), default_provider: Some("openai".to_string()), ..Config::default() }; run_models_refresh(&config, None, false).unwrap(); } #[test] fn run_models_refresh_rejects_unsupported_provider() { let tmp = TempDir::new().unwrap(); let config = Config { workspace_dir: tmp.path().to_path_buf(), default_provider: Some("venice".to_string()), ..Config::default() }; let err = run_models_refresh(&config, None, true).unwrap_err(); assert!(err .to_string() .contains("does not support live model discovery")); } // ── provider_env_var ──────────────────────────────────────── #[test] fn provider_env_var_known_providers() { assert_eq!(provider_env_var("openrouter"), "OPENROUTER_API_KEY"); assert_eq!(provider_env_var("anthropic"), "ANTHROPIC_API_KEY"); assert_eq!(provider_env_var("openai"), "OPENAI_API_KEY"); assert_eq!(provider_env_var("ollama"), "API_KEY"); // fallback assert_eq!(provider_env_var("xai"), "XAI_API_KEY"); assert_eq!(provider_env_var("grok"), "XAI_API_KEY"); // alias assert_eq!(provider_env_var("together"), "TOGETHER_API_KEY"); // alias assert_eq!(provider_env_var("together-ai"), "TOGETHER_API_KEY"); assert_eq!(provider_env_var("google"), "GEMINI_API_KEY"); // alias assert_eq!(provider_env_var("google-gemini"), "GEMINI_API_KEY"); // alias assert_eq!(provider_env_var("gemini"), "GEMINI_API_KEY"); assert_eq!(provider_env_var("nvidia"), "NVIDIA_API_KEY"); assert_eq!(provider_env_var("nvidia-nim"), "NVIDIA_API_KEY"); // alias assert_eq!(provider_env_var("build.nvidia.com"), "NVIDIA_API_KEY"); // alias } #[test] fn provider_env_var_unknown_falls_back() { assert_eq!(provider_env_var("some-new-provider"), "API_KEY"); } #[test] fn backend_key_from_choice_maps_supported_backends() { assert_eq!(backend_key_from_choice(0), "sqlite"); assert_eq!(backend_key_from_choice(1), "lucid"); assert_eq!(backend_key_from_choice(2), "markdown"); assert_eq!(backend_key_from_choice(3), "none"); assert_eq!(backend_key_from_choice(999), "sqlite"); } #[test] fn memory_backend_profile_marks_lucid_as_optional_sqlite_backed() { let lucid = memory_backend_profile("lucid"); assert!(lucid.auto_save_default); assert!(lucid.uses_sqlite_hygiene); assert!(lucid.sqlite_based); assert!(lucid.optional_dependency); let markdown = memory_backend_profile("markdown"); assert!(markdown.auto_save_default); assert!(!markdown.uses_sqlite_hygiene); let none = memory_backend_profile("none"); assert!(!none.auto_save_default); assert!(!none.uses_sqlite_hygiene); let custom = memory_backend_profile("custom-memory"); assert!(custom.auto_save_default); assert!(!custom.uses_sqlite_hygiene); } #[test] fn memory_config_defaults_for_lucid_enable_sqlite_hygiene() { let config = memory_config_defaults_for_backend("lucid"); assert_eq!(config.backend, "lucid"); assert!(config.auto_save); assert!(config.hygiene_enabled); assert_eq!(config.archive_after_days, 7); assert_eq!(config.purge_after_days, 30); assert_eq!(config.embedding_cache_size, 10000); } #[test] fn memory_config_defaults_for_none_disable_sqlite_hygiene() { let config = memory_config_defaults_for_backend("none"); assert_eq!(config.backend, "none"); assert!(!config.auto_save); assert!(!config.hygiene_enabled); assert_eq!(config.archive_after_days, 0); assert_eq!(config.purge_after_days, 0); assert_eq!(config.embedding_cache_size, 0); } }