Skip to main content

apollo/
bootstrap.rs

1//! Runtime/bootstrap helpers used by the CLI entrypoint.
2
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::config::Config;
7use crate::memory::embeddings::{create_embedding_provider, EmbeddingProvider};
8use crate::memory::search::{MemoryGetTool, MemorySearchTool, SessionSearchTool};
9use crate::memory::surreal::SurrealMemory;
10use crate::memory::MemoryBackend;
11use crate::policy::ExecutionPolicy;
12#[cfg(feature = "provider-anthropic")]
13use crate::providers::anthropic::AnthropicProvider;
14#[cfg(feature = "provider-ollama")]
15use crate::providers::ollama::OllamaProvider;
16use crate::providers::openai_compat::OpenAiCompatProvider;
17use crate::providers::Provider;
18use crate::tools::embeddings::{EmbeddingSearchTool, EmbeddingStatusTool, EmbeddingStoreTool};
19use crate::tools::file_ops::{FileReadTool, FileWriteTool};
20use crate::tools::shell::ShellTool;
21#[cfg(feature = "plugin-skills")]
22use crate::tools::skill_manager::SkillManagerTool;
23use crate::tools::toolsets::is_tool_enabled;
24use crate::tools::{BriefTool, ConfigTool, SleepTool, TodoWriteTool, Tool};
25#[cfg(feature = "plugin-advanced")]
26use crate::tools::{VibemaniaTool, WorktreeTool};
27
28pub fn load_config(path: &str) -> Config {
29    load_config_workspace(path, None)
30}
31
32pub fn require_config_file(path: &str) -> anyhow::Result<()> {
33    if !Path::new(path).exists() {
34        anyhow::bail!("Config not found at {path}. Run `apollo init` to create one.");
35    }
36    Ok(())
37}
38
39pub fn load_config_workspace(path: &str, workspace: Option<&Path>) -> Config {
40    let mut cfg = Config::load(path).unwrap_or_else(|_| {
41        tracing::warn!("Config not found at {}, using defaults", path);
42        Config::default_config()
43    });
44    if let Some(ws) = workspace {
45        crate::plugins::apply_workspace_manifest(&mut cfg, ws);
46    }
47
48    if let Ok(key) = std::env::var("ANTHROPIC_API_KEY") {
49        cfg.provider.api_key = Some(key.clone());
50        if key.contains("sk-ant-oat") && cfg.model.is_empty() {
51            cfg.model = "claude-sonnet-4-5".to_string();
52        }
53    }
54
55    if cfg.provider.api_key.is_none() {
56        if let Ok(token) = resolve_openclaw_token("anthropic") {
57            cfg.provider.api_key = Some(token);
58            if cfg.model.is_empty() {
59                cfg.model = "claude-sonnet-4-5".to_string();
60            }
61        }
62        #[cfg(feature = "provider-anthropic")]
63        {
64            if let Ok(_provider) =
65                crate::providers::anthropic::AnthropicProvider::from_env_or_oauth()
66            {
67                let _ = _provider;
68                if let Ok((token, _, _)) = crate::providers::oauth::load_oauth_token_from_file() {
69                    cfg.provider.api_key = Some(token);
70                    cfg.model = "claude-sonnet-4-5".to_string();
71                }
72            }
73        }
74
75        if cfg.provider.api_key.is_none() {
76            if let Ok(key) = std::env::var("OPENAI_API_KEY") {
77                cfg.provider.name = "openai".to_string();
78                cfg.provider.api_key = Some(key);
79            }
80        }
81    }
82
83    if cfg.provider.name == "ollama" && cfg.provider.base_url.is_none() {
84        if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
85            cfg.provider.base_url = Some(url);
86        }
87    }
88
89    if cfg.embeddings.api_key.is_none() {
90        match cfg.embeddings.provider.as_str() {
91            "openai" | "openai_compat" => {
92                if let Ok(key) = std::env::var("OPENAI_API_KEY") {
93                    cfg.embeddings.api_key = Some(key);
94                }
95            }
96            "ollama" | "local" => {
97                if cfg.embeddings.base_url.is_none() {
98                    if let Ok(url) = std::env::var("OLLAMA_BASE_URL") {
99                        cfg.embeddings.base_url = Some(url);
100                    }
101                }
102            }
103            "gemini" => {
104                if let Ok(key) = std::env::var("GEMINI_API_KEY") {
105                    cfg.embeddings.api_key = Some(key);
106                }
107            }
108            _ => {}
109        }
110    }
111
112    cfg
113}
114
115pub fn build_provider(cfg: &Config) -> Arc<dyn Provider> {
116    let api_key = cfg.provider.api_key.clone().unwrap_or_default();
117
118    match cfg.provider.name.as_str() {
119        #[cfg(feature = "provider-anthropic")]
120        "anthropic" | "claude" => {
121            let mut p = AnthropicProvider::new(&api_key);
122            if let Some(url) = &cfg.provider.base_url {
123                p = p.with_base_url(url);
124            }
125            Arc::new(p)
126        }
127        #[cfg(feature = "provider-copilot")]
128        "github-copilot" | "copilot" => {
129            if let Ok(p) = crate::providers::copilot::CopilotProvider::from_openclaw() {
130                Arc::new(p)
131            } else {
132                Arc::new(crate::providers::copilot::CopilotProvider::new(&api_key))
133            }
134        }
135        #[cfg(feature = "rs-ai")]
136        "chatgpt" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
137            "chatgpt",
138            &cfg.model,
139            &api_key,
140            cfg.provider.base_url.clone(),
141            None,
142        )),
143        #[cfg(feature = "rs-ai")]
144        "gemini" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
145            "gemini",
146            &cfg.model,
147            &api_key,
148            cfg.provider.base_url.clone(),
149            None,
150        )),
151        #[cfg(feature = "rs-ai")]
152        "xai" | "grok" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
153            "xai",
154            &cfg.model,
155            &api_key,
156            cfg.provider.base_url.clone(),
157            None,
158        )),
159        #[cfg(feature = "rs-ai")]
160        "cloudflare" => Arc::new(crate::providers::rs_ai::RsAiProvider::new(
161            "cloudflare",
162            &cfg.model,
163            &api_key,
164            None,
165            cfg.provider.base_url.clone(),
166        )),
167        "ollama" => {
168            #[cfg(feature = "provider-ollama")]
169            {
170                let url = cfg
171                    .provider
172                    .base_url
173                    .clone()
174                    .unwrap_or_else(|| "http://localhost:11434".into());
175                Arc::new(OllamaProvider::new(url))
176            }
177            #[cfg(not(feature = "provider-ollama"))]
178            {
179                panic!("provider=ollama requires building with the provider-ollama feature");
180            }
181        }
182        "openai" => Arc::new(OpenAiCompatProvider::openai(&api_key)),
183        "openrouter" => Arc::new(OpenAiCompatProvider::openrouter(&api_key)),
184        "groq" => Arc::new(OpenAiCompatProvider::groq(&api_key)),
185        "together" => Arc::new(OpenAiCompatProvider::together(&api_key)),
186        "mistral" => Arc::new(OpenAiCompatProvider::mistral(&api_key)),
187        "deepseek" => Arc::new(OpenAiCompatProvider::deepseek(&api_key)),
188        "fireworks" => Arc::new(OpenAiCompatProvider::fireworks(&api_key)),
189        "perplexity" => Arc::new(OpenAiCompatProvider::perplexity(&api_key)),
190        #[cfg(not(feature = "rs-ai"))]
191        "xai" | "grok" => Arc::new(OpenAiCompatProvider::xai(&api_key)),
192        "moonshot" | "kimi" => Arc::new(OpenAiCompatProvider::moonshot(&api_key)),
193        "venice" => Arc::new(OpenAiCompatProvider::venice(&api_key)),
194        "huggingface" => Arc::new(OpenAiCompatProvider::huggingface(&api_key)),
195        "siliconflow" => Arc::new(OpenAiCompatProvider::siliconflow(&api_key)),
196        "cerebras" => Arc::new(OpenAiCompatProvider::cerebras(&api_key)),
197        "minimax" => Arc::new(OpenAiCompatProvider::minimax(&api_key)),
198        "vercel" => Arc::new(OpenAiCompatProvider::vercel(&api_key)),
199        other => {
200            let url = cfg
201                .provider
202                .base_url
203                .clone()
204                .unwrap_or_else(|| "https://api.openai.com/v1".into());
205            Arc::new(OpenAiCompatProvider::new(&api_key, url, other))
206        }
207    }
208}
209
210pub fn build_base_tools(
211    workspace: &Path,
212    policy: Arc<ExecutionPolicy>,
213    memory: Arc<dyn MemoryBackend>,
214    embedding_provider: Option<Arc<dyn EmbeddingProvider>>,
215    provider: Arc<dyn Provider>,
216    cfg: &Config,
217    #[cfg(feature = "zkr-memory")] zkr_store: Option<Arc<crate::memory::zkr::ZkrStore>>,
218) -> Vec<Arc<dyn Tool>> {
219    let toolsets = &cfg.toolsets;
220    let mut tools: Vec<Arc<dyn Tool>> = vec![
221        Arc::new(ShellTool::new(workspace.to_path_buf(), Arc::clone(&policy))),
222        Arc::new(FileReadTool::new(workspace.to_path_buf())),
223        Arc::new(FileWriteTool::new(workspace.to_path_buf())),
224        Arc::new(crate::tools::edit::EditTool::new(workspace.to_path_buf())),
225        Arc::new(MemorySearchTool::new(workspace.to_path_buf())),
226        Arc::new(MemoryGetTool::new(workspace.to_path_buf())),
227        Arc::new(SessionSearchTool::new(Arc::clone(&memory))),
228        Arc::new(crate::tools::doctor::DoctorTool::new()),
229        Arc::new(crate::tools::session::ListModelsTool::new()),
230        Arc::new(crate::tools::dynamic::CreateToolTool::new(Arc::clone(
231            &policy,
232        ))),
233        Arc::new(crate::tools::dynamic::ListCustomToolsTool::new()),
234        Arc::new(BriefTool::new(
235            Arc::clone(&provider),
236            cfg.agent.fast_model.clone(),
237        )),
238        Arc::new(ConfigTool::new(workspace.join("apollo.json"))),
239        Arc::new(SleepTool),
240        Arc::new(TodoWriteTool::new(workspace.to_path_buf())),
241    ];
242    #[cfg(feature = "plugin-web")]
243    {
244        tools.push(Arc::new(crate::tools::web_search::WebSearchTool::new()));
245        tools.push(Arc::new(crate::tools::web_fetch::WebFetchTool::new()));
246    }
247    #[cfg(feature = "plugin-browser")]
248    tools.push(Arc::new(crate::tools::browser::BrowserTool::new()));
249    #[cfg(feature = "plugin-advanced")]
250    {
251        tools.push(Arc::new(crate::tools::mcp::McpTool::new()));
252        tools.push(Arc::new(VibemaniaTool::new(workspace.to_path_buf())));
253        tools.push(Arc::new(WorktreeTool::new(workspace.to_path_buf())));
254    }
255    #[cfg(feature = "plugin-skills")]
256    tools.push(Arc::new(SkillManagerTool::new(workspace.to_path_buf())));
257    if let Some(provider) = embedding_provider {
258        tools.push(Arc::new(EmbeddingStatusTool::new(Arc::clone(&provider))));
259        tools.push(Arc::new(EmbeddingStoreTool::new(
260            Arc::clone(&provider),
261            Arc::clone(&memory),
262        )));
263        tools.push(Arc::new(EmbeddingSearchTool::new(provider, memory)));
264    }
265    #[cfg(feature = "computer-use-praefectus")]
266    match crate::tools::praefectus::PraefectusTool::new(workspace, Arc::clone(&policy)) {
267        Ok(tool) => tools.push(Arc::new(tool)),
268        Err(error) => tracing::error!("failed to initialize Praefectus: {error}"),
269    }
270    #[cfg(feature = "zkr-memory")]
271    if let Some(store) = zkr_store {
272        tools.push(Arc::new(crate::tools::zkr::ZkrTool::new(store)));
273    }
274    #[cfg(feature = "rs-ai")]
275    {
276        let media_key = cfg.provider.api_key.clone().unwrap_or_default();
277        let media_provider = cfg.provider.name.clone();
278        tools.push(Arc::new(crate::tools::media::ImageGenerationTool::new(
279            workspace.to_path_buf(),
280            media_key.clone(),
281            media_provider.clone(),
282        )));
283        tools.push(Arc::new(crate::tools::media::TextToSpeechTool::new(
284            workspace.to_path_buf(),
285            media_key.clone(),
286            media_provider.clone(),
287        )));
288        tools.push(Arc::new(crate::tools::media::SpeechToTextTool::new(
289            media_key,
290            media_provider,
291        )));
292    }
293    tools
294        .into_iter()
295        .filter(|tool| is_tool_enabled(tool.name(), toolsets))
296        .collect()
297}
298
299pub fn build_embedding_provider(
300    cfg: &Config,
301) -> anyhow::Result<Option<Arc<dyn EmbeddingProvider>>> {
302    if !cfg.embeddings.enabled {
303        return Ok(None);
304    }
305
306    let provider_name = cfg.embeddings.provider.trim().to_ascii_lowercase();
307    let model = cfg.embeddings.model.clone();
308    let base_url = cfg.embeddings.base_url.clone();
309    let api_key = cfg.embeddings.api_key.clone();
310
311    let provider = create_embedding_provider(&provider_name, api_key, model, base_url)?;
312    Ok(Some(provider))
313}
314
315#[cfg(feature = "zkr-memory")]
316pub fn build_zkr_store(
317    workspace: &Path,
318    cfg: &Config,
319) -> anyhow::Result<Option<Arc<crate::memory::zkr::ZkrStore>>> {
320    if !cfg.zkr.enabled {
321        return Ok(None);
322    }
323    let person_id = cfg
324        .memory
325        .principal_id
326        .as_deref()
327        .unwrap_or(&cfg.zkr.person_id);
328    Ok(Some(Arc::new(crate::memory::zkr::ZkrStore::open(
329        &workspace.join(&cfg.zkr.database),
330        cfg.zkr.tenant_id.clone(),
331        person_id.to_string(),
332    )?)))
333}
334
335pub async fn build_memory_backend(
336    workspace: &Path,
337    cfg: &Config,
338) -> anyhow::Result<Arc<dyn MemoryBackend>> {
339    let storage_root = workspace.join(&cfg.storage.root);
340    std::fs::create_dir_all(&storage_root)?;
341    let backend = cfg.storage.backend.trim().to_ascii_lowercase();
342    if backend != "surreal" {
343        anyhow::bail!(
344            "storage.backend={} is not supported; only surreal is available",
345            cfg.storage.backend
346        );
347    }
348
349    let surreal_path = storage_root.join("memory.surreal");
350    let memory = SurrealMemory::new(surreal_path.as_path()).await?;
351    Ok(Arc::new(memory))
352}
353
354fn resolve_openclaw_token(provider: &str) -> anyhow::Result<String> {
355    let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("No home dir"))?;
356    let auth_path = home.join(".openclaw/agents/main/agent/auth-profiles.json");
357
358    if !auth_path.exists() {
359        return Err(anyhow::anyhow!("No auth-profiles.json found"));
360    }
361
362    let content = std::fs::read_to_string(&auth_path)?;
363    let data: serde_json::Value = serde_json::from_str(&content)?;
364
365    let profile_key = format!("{}:default", provider);
366    if let Some(profile) = data["profiles"][&profile_key].as_object() {
367        if let Some(token) = profile.get("token").and_then(|t| t.as_str()) {
368            if !token.is_empty() {
369                tracing::info!("Loaded {} token from OpenClaw auth-profiles", provider);
370                return Ok(token.to_string());
371            }
372        }
373        if let Some(key) = profile.get("key").and_then(|k| k.as_str()) {
374            if !key.is_empty() {
375                tracing::info!("Loaded {} API key from OpenClaw auth-profiles", provider);
376                return Ok(key.to_string());
377            }
378        }
379    }
380
381    if let Some(profiles) = data["profiles"].as_object() {
382        for (key, value) in profiles {
383            if let Some(p) = value["provider"].as_str() {
384                if p == provider {
385                    if let Some(token) = value["token"].as_str() {
386                        if !token.is_empty() {
387                            tracing::info!(
388                                "Loaded {} token from OpenClaw profile {}",
389                                provider,
390                                key
391                            );
392                            return Ok(token.to_string());
393                        }
394                    }
395                    if let Some(key_val) = value["key"].as_str() {
396                        if !key_val.is_empty() {
397                            tracing::info!("Loaded {} key from OpenClaw profile {}", provider, key);
398                            return Ok(key_val.to_string());
399                        }
400                    }
401                }
402            }
403        }
404    }
405
406    Err(anyhow::anyhow!(
407        "No {} credentials in auth-profiles",
408        provider
409    ))
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn require_config_file_errors_when_missing() {
418        let path = "/tmp/apollo-bootstrap-missing-config-test-xyz123.json";
419        let err = require_config_file(path).unwrap_err();
420        assert!(err.to_string().contains("Config not found"));
421        assert!(err.to_string().contains(path));
422    }
423
424    #[test]
425    fn require_config_file_ok_when_present() {
426        let dir = tempfile::tempdir().unwrap();
427        let path = dir.path().join("apollo.json");
428        std::fs::write(&path, "{}").unwrap();
429        require_config_file(path.to_str().unwrap()).unwrap();
430    }
431}