1use kowalski_core::config::Config;
4use serde::Serialize;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8pub 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
15pub 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#[derive(Debug, Clone, Serialize)]
30pub struct McpServerPublic {
31 pub name: String,
32 pub url: String,
33 pub transport: String,
34}
35
36#[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 pub llm: LlmDoctorJson,
53 pub operator: DoctorOperatorJson,
55}
56
57#[derive(Debug, Clone, Serialize)]
59pub struct DoctorOperatorJson {
60 pub mcp_servers_configured: usize,
61 pub postgres_memory_configured: bool,
62 pub config_divergence: Vec<String>,
64 pub mcp_streamable_session_note: &'static str,
66}
67
68pub 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#[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
111pub 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
133pub 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
215pub 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
239pub 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
275pub 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
302pub 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}