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 {
11 config_path
12 .map(PathBuf::from)
13 .unwrap_or_else(kowalski_core::config::default_config_path)
14}
15
16pub 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#[derive(Debug, Clone, Serialize)]
31pub struct McpServerPublic {
32 pub name: String,
33 pub url: String,
34 pub transport: String,
35}
36
37#[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 pub llm: LlmDoctorJson,
54 pub operator: DoctorOperatorJson,
56}
57
58#[derive(Debug, Clone, Serialize)]
60pub struct DoctorOperatorJson {
61 pub mcp_servers_configured: usize,
62 pub postgres_memory_configured: bool,
63 pub config_divergence: Vec<String>,
65 pub mcp_streamable_session_note: &'static str,
67}
68
69pub 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#[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
112pub 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
134pub 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
216pub 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
240pub 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
276pub 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
303pub 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}