Skip to main content

kowalski_cli/
ops.rs

1//! Operator commands: config validation, DB migrations, environment checks.
2
3use kowalski_core::config::Config;
4use serde::Serialize;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Default path for `[mcp]` and full config TOML (CLI and HTTP API).
9pub fn mcp_config_path(config_path: Option<&str>) -> PathBuf {
10    config_path
11        .map(PathBuf::from)
12        .unwrap_or_else(|| PathBuf::from("config.toml"))
13}
14
15/// Load full [`Config`] for `kowalski` server mode (HTTP chat + MCP). Missing file → [`Config::default`].
16pub fn load_kowalski_config_for_serve(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
17    if !path.exists() {
18        log::warn!(
19            "No config at {} — using defaults (Ollama localhost; add config.toml for MCP/tools)",
20            path.display()
21        );
22        return Ok(Config::default());
23    }
24    let raw = fs::read_to_string(path)?;
25    Ok(toml::from_str(&raw)?)
26}
27
28/// Public MCP server metadata for JSON APIs (no auth headers).
29#[derive(Debug, Clone, Serialize)]
30pub struct McpServerPublic {
31    pub name: String,
32    pub url: String,
33    pub transport: String,
34}
35
36/// Result of probing one MCP server (initialize + tools/list).
37#[derive(Debug, Clone, Serialize)]
38pub struct McpPingResult {
39    pub name: String,
40    pub url: String,
41    pub transport: String,
42    pub ok: bool,
43    pub tool_count: Option<usize>,
44    pub error: Option<String>,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct DoctorJson {
49    pub cli_version: String,
50    pub ollama: OllamaProbeJson,
51    /// From `[llm]` + `[ollama].model` (no API keys).
52    pub llm: LlmDoctorJson,
53    /// Non-secret operator hints (MCP count, Postgres flag, config deltas vs defaults).
54    pub operator: DoctorOperatorJson,
55}
56
57/// Printable summary for `/api/doctor` and CLI `doctor`.
58#[derive(Debug, Clone, Serialize)]
59pub struct DoctorOperatorJson {
60    pub mcp_servers_configured: usize,
61    pub postgres_memory_configured: bool,
62    /// Short labels for settings that differ from [`Config::default`] (no secret values).
63    pub config_divergence: Vec<String>,
64    /// How to observe Streamable HTTP MCP session ids after a server connects.
65    pub mcp_streamable_session_note: &'static str,
66}
67
68/// High-signal differences versus [`Config::default`] (for operators comparing deployments).
69pub fn config_divergence_lines(c: &Config) -> Vec<String> {
70    let d = Config::default();
71    let mut v = Vec::new();
72    if c.ollama.model != d.ollama.model {
73        v.push("ollama.model".into());
74    }
75    if c.ollama.host != d.ollama.host || c.ollama.port != d.ollama.port {
76        v.push("ollama host/port".into());
77    }
78    if c.memory.database_url.is_some() {
79        v.push("memory.database_url set".into());
80    }
81    if c.memory.episodic_path != d.memory.episodic_path {
82        v.push("memory.episodic_path".into());
83    }
84    if !c.mcp.servers.is_empty() {
85        v.push(format!("mcp.servers: {}", c.mcp.servers.len()));
86    }
87    if c.llm.provider != d.llm.provider {
88        v.push("llm.provider".into());
89    }
90    if c.llm.openai_api_base != d.llm.openai_api_base {
91        v.push("llm.openai_api_base".into());
92    }
93    v
94}
95
96/// Non-secret LLM routing snapshot for `/api/doctor`.
97#[derive(Debug, Clone, Serialize)]
98pub struct LlmDoctorJson {
99    pub provider: String,
100    pub model: String,
101    pub openai_api_base: Option<String>,
102}
103
104#[derive(Debug, Clone, Serialize)]
105pub struct OllamaProbeJson {
106    pub url: String,
107    pub ok: bool,
108    pub detail: String,
109}
110
111/// List `[mcp.servers]` entries from TOML (headers omitted).
112pub fn list_mcp_servers_public(
113    path: &Path,
114) -> Result<Vec<McpServerPublic>, Box<dyn std::error::Error>> {
115    use crate::config::load_mcp_config_from_file;
116
117    let mcp = load_mcp_config_from_file(path)?;
118    Ok(mcp
119        .servers
120        .iter()
121        .map(|s| McpServerPublic {
122            name: s.name.clone(),
123            url: s.url.clone(),
124            transport: match s.transport {
125                kowalski_core::config::McpTransport::Http => "http".to_string(),
126                kowalski_core::config::McpTransport::Sse => "sse".to_string(),
127                kowalski_core::config::McpTransport::Stdio => "stdio".to_string(),
128            },
129        })
130        .collect())
131}
132
133/// Run initialize + tools/list for each configured MCP server.
134pub async fn mcp_ping_results(
135    path: &Path,
136) -> Result<Vec<McpPingResult>, Box<dyn std::error::Error>> {
137    use crate::config::load_mcp_config_from_file;
138
139    let mcp = load_mcp_config_from_file(path)?;
140    let mut out = Vec::with_capacity(mcp.servers.len());
141    for server in &mcp.servers {
142        let transport = match server.transport {
143            kowalski_core::config::McpTransport::Http => "http",
144            kowalski_core::config::McpTransport::Sse => "sse",
145            kowalski_core::config::McpTransport::Stdio => "stdio",
146        };
147        let url_display = if server.url.trim().is_empty() {
148            server.command.join(" ")
149        } else {
150            server.url.clone()
151        };
152        let result: Result<
153            Vec<kowalski_core::mcp::types::McpToolDescription>,
154            kowalski_core::KowalskiError,
155        > = if matches!(server.transport, kowalski_core::config::McpTransport::Stdio) {
156            match kowalski_core::McpStdioClient::connect(server).await {
157                Ok(c) => c.list_tools().await,
158                Err(e) => Err(e),
159            }
160        } else {
161            match kowalski_core::mcp::McpClient::connect_server(server).await {
162                Ok(c) => c.list_tools().await,
163                Err(e) => Err(e),
164            }
165        };
166        match result {
167            Ok(tools) => out.push(McpPingResult {
168                name: server.name.clone(),
169                url: url_display,
170                transport: transport.to_string(),
171                ok: true,
172                tool_count: Some(tools.len()),
173                error: None,
174            }),
175            Err(e) => out.push(McpPingResult {
176                name: server.name.clone(),
177                url: url_display,
178                transport: transport.to_string(),
179                ok: false,
180                tool_count: None,
181                error: Some(e.to_string()),
182            }),
183        }
184    }
185    Ok(out)
186}
187
188async fn probe_ollama_tags(base: &str) -> OllamaProbeJson {
189    let base = base.trim_end_matches('/');
190    let tags_url = format!("{}/api/tags", base);
191    match reqwest::get(&tags_url).await {
192        Ok(r) => {
193            if r.status().is_success() {
194                OllamaProbeJson {
195                    url: tags_url,
196                    ok: true,
197                    detail: format!("HTTP {}", r.status()),
198                }
199            } else {
200                OllamaProbeJson {
201                    url: tags_url,
202                    ok: false,
203                    detail: format!("HTTP {}", r.status()),
204                }
205            }
206        }
207        Err(e) => OllamaProbeJson {
208            url: tags_url,
209            ok: false,
210            detail: e.to_string(),
211        },
212    }
213}
214
215/// JSON payload for `/api/doctor` (and similar UIs).
216pub async fn doctor_json(ollama_base: Option<String>, config: Option<&Config>) -> DoctorJson {
217    let base = ollama_base.unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
218    let ollama = probe_ollama_tags(&base).await;
219    let c = config.cloned().unwrap_or_default();
220    let llm = LlmDoctorJson {
221        provider: c.llm.provider.clone(),
222        model: c.ollama.model.clone(),
223        openai_api_base: c.llm.openai_api_base.clone(),
224    };
225    let operator = DoctorOperatorJson {
226        mcp_servers_configured: c.mcp.servers.len(),
227        postgres_memory_configured: kowalski_core::config::memory_uses_postgres(&c.memory),
228        config_divergence: config_divergence_lines(&c),
229        mcp_streamable_session_note: "After initialize, Streamable HTTP MCP session ids are available via `McpClient::session_id()` (and `McpClient::shutdown()` clears the session).",
230    };
231    DoctorJson {
232        cli_version: env!("CARGO_PKG_VERSION").to_string(),
233        ollama,
234        llm,
235        operator,
236    }
237}
238
239/// Validate TOML and optionally a full [`Config`] parse.
240pub fn run_config_check(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
241    let raw = fs::read_to_string(path)?;
242    let _toml: toml::Value = toml::from_str(&raw)?;
243    println!("OK — valid TOML ({})", path.display());
244
245    match toml::from_str::<Config>(&raw) {
246        Ok(c) => {
247            println!("OK — parses as Kowalski core `Config`");
248            println!(
249                "  ollama: {}:{} / model {}",
250                c.ollama.host, c.ollama.port, c.ollama.model
251            );
252            println!("  memory: episodic_path = {}", c.memory.episodic_path);
253            if let Some(ref u) = c.memory.database_url {
254                println!("  memory.database_url = {}", u);
255            } else {
256                println!("  memory.database_url = (unset — Tier 2 SQLite file only)");
257            }
258            println!("  mcp servers: {}", c.mcp.servers.len());
259            println!(
260                "  llm: provider = {}, model = {}",
261                c.llm.provider, c.ollama.model
262            );
263            if let Some(ref b) = c.llm.openai_api_base {
264                println!("  llm.openai_api_base = {}", b);
265            }
266        }
267        Err(e) => {
268            println!("Note — not a full core `Config` (fix or use partial TOML only):");
269            println!("  {}", e);
270        }
271    }
272    Ok(())
273}
274
275/// Run `memory.database_url` migrations from `--url` or from `memory.database_url` in TOML.
276pub async fn run_db_migrate(
277    url: Option<String>,
278    config: Option<String>,
279) -> Result<(), Box<dyn std::error::Error>> {
280    let resolved = if let Some(u) = url {
281        u
282    } else {
283        let path = config
284            .map(PathBuf::from)
285            .unwrap_or_else(|| PathBuf::from("config.toml"));
286        let raw = fs::read_to_string(&path)?;
287        let v: toml::Value = toml::from_str(&raw)?;
288        let url = v
289            .get("memory")
290            .and_then(|m| m.get("database_url"))
291            .and_then(|x| x.as_str())
292            .ok_or("No memory.database_url in config and no --url")?;
293        url.to_string()
294    };
295
296    println!("Running migrations for {}", resolved);
297    kowalski_core::db::run_migrations(&resolved).await?;
298    println!("Done.");
299    Ok(())
300}
301
302/// Print versions and probe local Ollama (default `http://127.0.0.1:11434`).
303pub async fn run_doctor(ollama_base: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
304    let config = load_optional_config_default_path();
305    let j = doctor_json(ollama_base, config.as_ref()).await;
306    println!("kowalski-cli {}", j.cli_version);
307    println!(
308        "LLM: provider = {}, model = {}",
309        j.llm.provider, j.llm.model
310    );
311    if let Some(ref b) = j.llm.openai_api_base {
312        println!("LLM: openai_api_base = {}", b);
313    }
314    if j.ollama.ok {
315        println!("Ollama: OK — {}", j.ollama.url);
316    } else if j.ollama.detail.starts_with("HTTP ") {
317        println!("Ollama: {} — {}", j.ollama.detail, j.ollama.url);
318    } else {
319        println!(
320            "Ollama: unreachable ({}) — {}",
321            j.ollama.url, j.ollama.detail
322        );
323    }
324    println!(
325        "Operator: MCP servers in config = {}, postgres memory URL = {}",
326        j.operator.mcp_servers_configured, j.operator.postgres_memory_configured
327    );
328    if !j.operator.config_divergence.is_empty() {
329        println!(
330            "Config vs defaults: {}",
331            j.operator.config_divergence.join(", ")
332        );
333    }
334    Ok(())
335}
336
337fn load_optional_config_default_path() -> Option<Config> {
338    let path = PathBuf::from("config.toml");
339    if !path.exists() {
340        return None;
341    }
342    let raw = fs::read_to_string(&path).ok()?;
343    toml::from_str(&raw).ok()
344}