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