Skip to main content

mermaid_cli/cli/
commands.rs

1use anyhow::Result;
2use std::sync::Arc;
3
4use crate::{
5    app::{Config, get_config_dir, init_config, load_config},
6    models::{BackendConfig, Model, PROVIDER_REGISTRY, lookup_provider},
7    ollama::is_installed as is_ollama_installed,
8    utils::{resolve_api_key, resolve_api_key_with_fallback},
9};
10
11use super::Commands;
12
13/// Handle CLI subcommands
14/// Returns Ok(true) if the command was handled and we should exit
15/// Returns Ok(false) if we should continue to the main application
16pub async fn handle_command(command: &Commands, config: &Config) -> Result<bool> {
17    match command {
18        Commands::Init => {
19            println!("Initializing Mermaid configuration...");
20            init_config()?;
21            println!("Configuration initialized successfully!");
22            Ok(true)
23        },
24        Commands::List => {
25            list_models(config).await?;
26            Ok(true)
27        },
28        Commands::Version => {
29            show_version();
30            Ok(true)
31        },
32        Commands::Status => {
33            show_status(config).await?;
34            Ok(true)
35        },
36        Commands::Add { name } => {
37            crate::mcp::add_server(name).await?;
38            Ok(true)
39        },
40        Commands::Remove { name } => {
41            crate::mcp::remove_server(name).await?;
42            Ok(true)
43        },
44        Commands::Mcp => {
45            show_mcp_servers();
46            Ok(true)
47        },
48        Commands::CloudSetup => {
49            // Interactive stdin prompt — runs before the TUI enters
50            // raw mode so rpassword works. The in-TUI slash command
51            // `/cloud-setup` just points users here.
52            let _ = crate::ollama::setup_cloud_interactive();
53            Ok(true)
54        },
55        Commands::Chat => Ok(false),       // Continue to chat interface
56        Commands::Run { .. } => Ok(false), // Handled by main.rs
57    }
58}
59
60/// List available models across all backends (honors user config).
61pub async fn list_models(config: &Config) -> Result<()> {
62    let ollama_models = list_ollama_models(config).await;
63    if ollama_models.is_empty() {
64        println!("No Ollama models installed locally.");
65    } else {
66        println!("Ollama models (local/cloud):");
67        for name in &ollama_models {
68            println!("  - ollama/{}", name);
69        }
70    }
71
72    println!("\nConfigured remote providers:");
73    let mut any = false;
74    for profile in PROVIDER_REGISTRY {
75        let env = config
76            .providers
77            .get(profile.name)
78            .and_then(|c| c.api_key_env.as_deref())
79            .unwrap_or(profile.api_key_env);
80        if resolve_api_key(env, None).is_some() {
81            any = true;
82            println!("  - {} (via ${})", profile.name, env);
83        }
84    }
85    if !any {
86        println!("  (none — set a provider API key env var to enable)");
87    }
88    println!("\nSwitch models in-session with /model <name>.");
89    Ok(())
90}
91
92/// Ask the local Ollama daemon for its list of models. Empty on
93/// failure — the status widget separately shows whether Ollama is
94/// reachable.
95async fn list_ollama_models(config: &Config) -> Vec<String> {
96    use crate::models::adapters::ollama::OllamaAdapter;
97    let backend = BackendConfig {
98        ollama_url: format!("http://{}:{}", config.ollama.host, config.ollama.port),
99        timeout_secs: 5,
100        max_idle_per_host: 2,
101    };
102    match OllamaAdapter::new("__list__", Arc::new(backend)).await {
103        Ok(adapter) => adapter.list_models().await.unwrap_or_default(),
104        Err(_) => Vec::new(),
105    }
106}
107
108/// Show version information
109pub fn show_version() {
110    println!("Mermaid v{}", env!("CARGO_PKG_VERSION"));
111    println!("   An open-source, model-agnostic AI pair programmer");
112}
113
114/// Show configured MCP servers
115fn show_mcp_servers() {
116    let config = load_config().unwrap_or_default();
117
118    if config.mcp_servers.is_empty() {
119        println!("No MCP servers configured.\n");
120        println!("Add one with: mermaid add <name>");
121        println!("Examples:");
122        println!("  mermaid add context7     # Library documentation");
123        println!("  mermaid add playwright   # Browser automation");
124        println!("  mermaid add memory       # Persistent knowledge graph");
125        return;
126    }
127
128    println!("Configured MCP servers:\n");
129    for (name, server_cfg) in &config.mcp_servers {
130        let package = server_cfg
131            .args
132            .iter()
133            .find(|a| !a.starts_with('-'))
134            .unwrap_or(&server_cfg.command);
135        let env_keys: Vec<&String> = server_cfg.env.keys().collect();
136        let env_display = if env_keys.is_empty() {
137            String::new()
138        } else {
139            format!(
140                " (env: {})",
141                env_keys
142                    .iter()
143                    .map(|k| k.as_str())
144                    .collect::<Vec<_>>()
145                    .join(", ")
146            )
147        };
148        println!("  {} — {}{}", name, package, env_display);
149    }
150    println!("\nManage with: mermaid add <name> / mermaid remove <name>");
151}
152
153/// Show status of all dependencies
154async fn show_status(config: &Config) -> Result<()> {
155    println!("Mermaid Status:");
156    println!();
157
158    // Check remote providers by API-key env presence (matches the
159    // routing ProviderFactory uses when the user picks a model).
160    let mut available: Vec<&'static str> = Vec::new();
161    for profile in PROVIDER_REGISTRY {
162        let env = config
163            .providers
164            .get(profile.name)
165            .and_then(|c| c.api_key_env.as_deref())
166            .unwrap_or(profile.api_key_env);
167        if resolve_api_key(env, None).is_some() {
168            available.push(profile.name);
169        }
170    }
171    if available.is_empty() {
172        println!("  [WARNING] Remote providers: none (no API keys in env)");
173    } else {
174        println!("  [OK] Remote providers: {}", available.join(", "));
175    }
176
177    // Check Ollama (via HTTP, so remote deployments are honored).
178    if is_ollama_installed() {
179        let models = list_ollama_models(config).await;
180        if models.is_empty() {
181            println!("  [WARNING] Ollama: Installed (no models)");
182        } else {
183            println!("  [OK] Ollama: Running ({} models installed)", models.len());
184            for model in models.iter().take(3) {
185                println!("      - {}", model);
186            }
187            if models.len() > 3 {
188                println!("      ... and {} more", models.len() - 3);
189            }
190        }
191    } else {
192        println!("  [ERROR] Ollama: Not installed");
193    }
194
195    // Check configuration (uses platform-specific path via ProjectDirs)
196    if let Ok(config_dir) = get_config_dir() {
197        let config_path = config_dir.join("config.toml");
198        if config_path.exists() {
199            println!("  [OK] Configuration: {}", config_path.display());
200        } else {
201            println!("  [WARNING] Configuration: Not found (using defaults)");
202        }
203    }
204
205    // MCP Servers
206    if config.mcp_servers.is_empty() {
207        println!("  [INFO] MCP Servers: None configured (use 'mermaid add <name>')");
208    } else {
209        println!(
210            "  [OK] MCP Servers: {} configured",
211            config.mcp_servers.len()
212        );
213        for (name, server_cfg) in &config.mcp_servers {
214            println!(
215                "      - {} ({})",
216                name,
217                server_cfg.args.get(1).unwrap_or(&server_cfg.command)
218            );
219        }
220    }
221
222    // Project instructions (Step 5h). Walks UP from cwd to git root or
223    // $HOME to find the nearest MERMAID.md.
224    {
225        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
226        match crate::app::instructions::find_mermaid_md(&cwd) {
227            Some(path) => match crate::app::instructions::load_from_path(&path) {
228                Some(loaded) => {
229                    println!(
230                        "  [OK] MERMAID.md: {} ({} bytes{})",
231                        loaded.path.display(),
232                        loaded.byte_len,
233                        if loaded.truncated { ", truncated" } else { "" }
234                    );
235                },
236                None => {
237                    println!(
238                        "  [WARNING] MERMAID.md: found at {} but unreadable",
239                        path.display()
240                    );
241                },
242            },
243            None => {
244                println!(
245                    "  [INFO] MERMAID.md: not found (create one to add persistent project instructions)"
246                );
247            },
248        }
249    }
250
251    // OpenAI-compatible providers — list anything from the built-in
252    // registry whose API key resolves, plus any user-defined custom
253    // providers. No network probe (would slow `mermaid status`).
254    show_provider_status(config);
255
256    // Environment variables (for API providers)
257    println!("\n  Environment:");
258    if std::env::var("OLLAMA_API_KEY").is_ok() {
259        println!("    - OLLAMA_API_KEY: Set (for Ollama Cloud)");
260    }
261
262    println!();
263    Ok(())
264}
265
266/// Print the remote-providers status block. Includes Anthropic (bespoke
267/// Messages API) and any OpenAI-compatible provider whose API key resolves.
268/// Custom providers from `[providers.<name>]` are listed if `base_url`
269/// and `api_key_env` are both set and the env var resolves.
270fn show_provider_status(config: &Config) {
271    let mut configured: Vec<(String, String)> = Vec::new(); // (name, base_url)
272
273    // Anthropic — checked first because it's not in the OpenAI-compat
274    // registry but is a top-tier provider users care about.
275    let anth_cfg = config.providers.get("anthropic");
276    if resolve_api_key(
277        "ANTHROPIC_API_KEY",
278        anth_cfg.and_then(|c| c.api_key_env.as_deref()),
279    )
280    .is_some()
281    {
282        let url = anth_cfg
283            .and_then(|c| c.base_url.clone())
284            .unwrap_or_else(|| "https://api.anthropic.com/v1".to_string());
285        configured.push(("anthropic".to_string(), url));
286    }
287
288    // Gemini — also bespoke (not in OpenAI-compat registry).
289    let gem_cfg = config.providers.get("gemini");
290    if resolve_api_key_with_fallback(
291        "GOOGLE_API_KEY",
292        "GEMINI_API_KEY",
293        gem_cfg.and_then(|c| c.api_key_env.as_deref()),
294    )
295    .is_some()
296    {
297        let url = gem_cfg
298            .and_then(|c| c.base_url.clone())
299            .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string());
300        configured.push(("gemini".to_string(), url));
301    }
302
303    for profile in PROVIDER_REGISTRY {
304        let user_cfg = config.providers.get(profile.name);
305        let api_key_present = resolve_api_key(
306            profile.api_key_env,
307            user_cfg.and_then(|c| c.api_key_env.as_deref()),
308        )
309        .is_some();
310        if api_key_present {
311            let url = user_cfg
312                .and_then(|c| c.base_url.clone())
313                .unwrap_or_else(|| profile.base_url.to_string());
314            configured.push((profile.name.to_string(), url));
315        }
316    }
317
318    // Custom providers — anything in config.providers not in registry
319    // and not "anthropic" / "gemini" (already handled above).
320    for (name, cfg) in &config.providers {
321        if name == "anthropic" || name == "gemini" || lookup_provider(name).is_some() {
322            continue;
323        }
324        if let (Some(url), Some(env)) = (&cfg.base_url, cfg.api_key_env.as_deref())
325            && resolve_api_key(env, None).is_some()
326        {
327            configured.push((name.clone(), url.clone()));
328        }
329    }
330
331    if configured.is_empty() {
332        println!(
333            "  [INFO] Remote providers: None configured (set $ANTHROPIC_API_KEY, \
334             $GOOGLE_API_KEY, $OPENAI_API_KEY, $GROQ_API_KEY, $OPENROUTER_API_KEY, etc., or \
335             add [providers.<name>] to config.toml)"
336        );
337    } else {
338        println!("  [OK] Remote providers: {} configured", configured.len());
339        for (name, url) in configured {
340            println!("      - {} ({})", name, url);
341        }
342    }
343}