1use agent_diva_core::config::validate::validate_config;
2use agent_diva_core::config::{Config, ConfigLoader, ProviderConfig, ProvidersConfig};
3use agent_diva_core::cron::CronService;
4use agent_diva_core::utils::sync_workspace_templates;
5use agent_diva_providers::{
6 fetch_provider_model_catalog, LiteLLMClient, ProviderAccess, ProviderCatalogService,
7 ProviderModelCatalog, ProviderRegistry, ProviderSpec,
8};
9use anyhow::Result;
10use serde::Serialize;
11use std::path::{Path, PathBuf};
12use std::sync::Arc;
13
14#[derive(Clone)]
15pub struct CliRuntime {
16 loader: ConfigLoader,
17 workspace_override: Option<PathBuf>,
18}
19
20#[derive(Clone, Debug, Serialize)]
21pub struct PathReport {
22 pub config_path: String,
23 pub config_dir: String,
24 pub runtime_dir: String,
25 pub workspace: String,
26 pub cron_store: String,
27 pub bridge_dir: String,
28 pub whatsapp_auth_dir: String,
29 pub whatsapp_media_dir: String,
30}
31
32#[derive(Clone, Debug, Serialize)]
33pub struct ProviderStatus {
34 pub name: String,
35 pub display_name: String,
36 pub default_model: Option<String>,
37 pub configurable: bool,
38 pub configured: bool,
39 pub ready: bool,
40 pub uses_api_base: bool,
41 pub provider_for_default_model: bool,
42 pub current: bool,
43 pub model: Option<String>,
44 pub api_base: Option<String>,
45 pub missing_fields: Vec<String>,
46}
47
48#[derive(Clone, Debug, Serialize)]
49pub struct ChannelStatus {
50 pub name: String,
51 pub enabled: bool,
52 pub ready: bool,
53 pub missing_fields: Vec<String>,
54 pub notes: Vec<String>,
55}
56
57#[derive(Clone, Debug, Serialize)]
58pub struct DoctorReport {
59 pub valid: bool,
60 pub ready: bool,
61 pub errors: Vec<String>,
62 pub warnings: Vec<String>,
63 pub provider: Option<String>,
64 pub channels: Vec<ChannelStatus>,
65}
66
67#[derive(Clone, Debug, Serialize)]
68pub struct ProviderStatusReport {
69 pub current_model: String,
70 pub current_provider: Option<String>,
71 pub providers: Vec<ProviderStatus>,
72}
73
74#[derive(Clone, Debug, Serialize)]
75pub struct StatusReport {
76 pub config: PathReport,
77 pub default_model: String,
78 pub default_provider: Option<String>,
79 pub logging: StatusLoggingReport,
80 pub providers: Vec<ProviderStatus>,
81 pub channels: Vec<ChannelStatus>,
82 pub cron_jobs: usize,
83 pub mcp_servers: StatusMcpReport,
84 pub doctor: StatusDoctorSummary,
85}
86
87#[derive(Clone, Debug, Serialize)]
88pub struct StatusLoggingReport {
89 pub level: String,
90 pub format: String,
91 pub dir: String,
92}
93
94#[derive(Clone, Debug, Serialize)]
95pub struct StatusMcpReport {
96 pub configured: usize,
97 pub disabled: usize,
98}
99
100#[derive(Clone, Debug, Serialize)]
101pub struct StatusDoctorSummary {
102 pub valid: bool,
103 pub ready: bool,
104 pub errors: Vec<String>,
105 pub warnings: Vec<String>,
106}
107
108pub fn expand_tilde(path: &str) -> PathBuf {
109 if let Some(rest) = path.strip_prefix("~/") {
110 if let Some(home) = dirs::home_dir() {
111 return home.join(rest);
112 }
113 }
114 PathBuf::from(path)
115}
116
117impl CliRuntime {
118 pub fn from_paths(
119 config: Option<PathBuf>,
120 config_dir: Option<PathBuf>,
121 workspace_override: Option<PathBuf>,
122 ) -> Self {
123 let loader = if let Some(path) = config {
124 ConfigLoader::with_file(path)
125 } else if let Some(dir) = config_dir {
126 ConfigLoader::with_dir(dir)
127 } else {
128 ConfigLoader::new()
129 };
130
131 Self {
132 loader,
133 workspace_override,
134 }
135 }
136
137 pub fn loader(&self) -> &ConfigLoader {
138 &self.loader
139 }
140
141 pub fn config_path(&self) -> &Path {
142 self.loader.config_path()
143 }
144
145 pub fn config_dir(&self) -> &Path {
146 self.loader.config_dir()
147 }
148
149 pub fn runtime_dir(&self) -> &Path {
150 self.loader.config_dir()
151 }
152
153 pub fn load_config(&self) -> Result<Config> {
154 Ok(self.loader.load()?)
155 }
156
157 pub fn effective_workspace(&self, config: &Config) -> PathBuf {
158 if let Some(workspace) = &self.workspace_override {
159 workspace.clone()
160 } else {
161 expand_tilde(&config.agents.defaults.workspace)
162 }
163 }
164
165 pub fn cron_store_path(&self) -> PathBuf {
166 self.config_dir()
167 .join("data")
168 .join("cron")
169 .join("jobs.json")
170 }
171
172 pub fn bridge_dir(&self) -> PathBuf {
173 self.config_dir().join("bridge")
174 }
175
176 pub fn whatsapp_auth_dir(&self) -> PathBuf {
177 self.config_dir().join("whatsapp-auth")
178 }
179
180 pub fn whatsapp_media_dir(&self) -> PathBuf {
181 self.config_dir().join("whatsapp-media")
182 }
183
184 pub fn path_report(&self, config: &Config) -> PathReport {
185 PathReport {
186 config_path: self.config_path().display().to_string(),
187 config_dir: self.config_dir().display().to_string(),
188 runtime_dir: self.runtime_dir().display().to_string(),
189 workspace: self.effective_workspace(config).display().to_string(),
190 cron_store: self.cron_store_path().display().to_string(),
191 bridge_dir: self.bridge_dir().display().to_string(),
192 whatsapp_auth_dir: self.whatsapp_auth_dir().display().to_string(),
193 whatsapp_media_dir: self.whatsapp_media_dir().display().to_string(),
194 }
195 }
196}
197
198pub fn provider_config_by_name<'a>(
199 providers: &'a ProvidersConfig,
200 name: &str,
201) -> Option<&'a ProviderConfig> {
202 providers.get(name)
203}
204
205pub fn provider_config_by_name_mut<'a>(
206 providers: &'a mut ProvidersConfig,
207 name: &str,
208) -> Option<&'a mut ProviderConfig> {
209 providers.get_mut(name)
210}
211
212pub fn provider_has_config_slot(name: &str) -> bool {
213 ProvidersConfig::is_builtin_provider(name)
214}
215
216pub fn provider_registry() -> ProviderRegistry {
217 ProviderRegistry::new()
218}
219
220pub fn manageable_provider_specs() -> Vec<ProviderSpec> {
221 provider_registry()
222 .all()
223 .iter()
224 .filter(|spec| provider_has_config_slot(&spec.name))
225 .cloned()
226 .collect()
227}
228
229pub fn provider_spec_by_name(name: &str) -> Option<ProviderSpec> {
230 manageable_provider_specs()
231 .into_iter()
232 .find(|spec| spec.name == name)
233}
234
235pub fn default_model_from_registry(provider_name: &str) -> Option<String> {
236 provider_registry()
237 .find_by_name(provider_name)
238 .and_then(|spec| spec.default_model().map(ToString::to_string))
239}
240
241pub fn infer_provider_name_from_model(model: &str) -> Option<String> {
242 let registry = provider_registry();
243 model
244 .split('/')
245 .next()
246 .and_then(|prefix| registry.find_by_name(prefix))
247 .or_else(|| registry.find_by_model(model))
248 .map(|spec| spec.name.clone())
249}
250
251pub fn current_provider_name(config: &Config) -> Option<String> {
252 let preferred_provider = config
253 .agents
254 .defaults
255 .provider
256 .as_deref()
257 .map(str::trim)
258 .filter(|value| !value.is_empty());
259 let inferred_provider = infer_provider_name_from_model(&config.agents.defaults.model);
260
261 if let Some(provider_name) = preferred_provider {
262 if config.providers.get_custom(provider_name).is_some() {
263 return Some(provider_name.to_string());
264 }
265 if inferred_provider
266 .as_deref()
267 .is_some_and(|inferred| inferred != provider_name)
268 {
269 return inferred_provider;
270 }
271 if ProviderCatalogService::new()
272 .get_provider_view(config, provider_name)
273 .is_some()
274 {
275 return Some(provider_name.to_string());
276 }
277 }
278
279 inferred_provider
280}
281
282pub fn resolve_provider_name_for_model(
283 config: &Config,
284 model: &str,
285 preferred_provider: Option<&str>,
286) -> Option<String> {
287 let preferred_provider = preferred_provider
288 .map(str::trim)
289 .filter(|value| !value.is_empty());
290 let inferred_provider = infer_provider_name_from_model(model);
291
292 if let Some(provider_name) = preferred_provider {
293 if config.providers.get_custom(provider_name).is_some() {
294 return Some(provider_name.to_string());
295 }
296 if inferred_provider
297 .as_deref()
298 .is_some_and(|inferred| inferred != provider_name)
299 {
300 return inferred_provider;
301 }
302 if let Some(spec) = provider_spec_by_name(provider_name) {
303 return Some(spec.name);
304 }
305 }
306
307 inferred_provider.or_else(|| {
308 (model == config.agents.defaults.model)
309 .then(|| current_provider_name(config))
310 .flatten()
311 })
312}
313
314pub fn session_channel_and_chat_id(session_key: &str) -> (&str, &str) {
315 session_key.split_once(':').unwrap_or(("cli", session_key))
316}
317
318pub fn build_provider(config: &Config, model: &str) -> Result<LiteLLMClient> {
319 let catalog = ProviderCatalogService::new();
320 let provider_name = resolve_provider_name_for_model(
321 config,
322 model,
323 (model == config.agents.defaults.model)
324 .then_some(config.agents.defaults.provider.as_deref())
325 .flatten(),
326 )
327 .ok_or_else(|| anyhow::anyhow!("No provider found for model: {}", model))?;
328 let access = catalog
329 .get_provider_access(config, &provider_name)
330 .unwrap_or_else(|| ProviderAccess::from_config(None));
331 let api_key = access.api_key;
332 let api_base = access.api_base;
333 let extra_headers = (!access.extra_headers.is_empty()).then(|| {
334 access
335 .extra_headers
336 .into_iter()
337 .collect::<std::collections::HashMap<String, String>>()
338 });
339
340 Ok(LiteLLMClient::new(
341 api_key,
342 api_base,
343 model.to_string(),
344 extra_headers,
345 Some(provider_name),
346 config.agents.defaults.reasoning_effort.clone(),
347 ))
348}
349
350pub fn set_provider_credentials(
351 config: &mut Config,
352 provider_name: &str,
353 api_key: Option<String>,
354 api_base: Option<String>,
355) {
356 if let Some(provider) = provider_config_by_name_mut(&mut config.providers, provider_name) {
357 if let Some(api_key) = api_key {
358 provider.api_key = api_key;
359 }
360 if api_base.is_some() {
361 provider.api_base = api_base;
362 }
363 }
364}
365
366pub fn available_provider_names() -> Vec<String> {
367 ProvidersConfig::builtin_provider_names()
368 .iter()
369 .map(|name| (*name).to_string())
370 .collect()
371}
372
373pub fn provider_access_by_name(config: &Config, provider_name: &str) -> ProviderAccess {
374 ProviderCatalogService::new()
375 .get_provider_access(config, provider_name)
376 .unwrap_or_else(|| ProviderAccess::from_config(None))
377}
378
379pub async fn fetch_provider_models(
380 config: &Config,
381 provider_name: &str,
382 allow_static_fallback: bool,
383) -> Result<ProviderModelCatalog> {
384 let spec = provider_registry()
385 .find_by_name(provider_name)
386 .cloned()
387 .ok_or_else(|| {
388 anyhow::anyhow!(
389 "Unknown or unmanaged provider '{}'. Supported: {}",
390 provider_name,
391 available_provider_names().join(", ")
392 )
393 })?;
394 let access = provider_access_by_name(config, provider_name);
395
396 Ok(fetch_provider_model_catalog(&spec, &access, allow_static_fallback).await)
397}
398
399pub fn ensure_workspace_templates(workspace: &Path) -> Result<Vec<String>> {
400 std::fs::create_dir_all(workspace)?;
401 std::fs::create_dir_all(workspace.join("skills"))?;
402 Ok(sync_workspace_templates(workspace)?)
403}
404
405pub fn redact_sensitive_value(key: &str, value: &mut serde_json::Value) {
406 let lowered = key.to_ascii_lowercase();
407 let looks_sensitive = ["api_key", "token", "secret", "password"]
408 .iter()
409 .any(|segment| lowered.contains(segment));
410
411 match value {
412 serde_json::Value::Object(map) => {
413 for (nested_key, nested_value) in map.iter_mut() {
414 redact_sensitive_value(nested_key, nested_value);
415 }
416 }
417 serde_json::Value::Array(items) => {
418 for item in items {
419 redact_sensitive_value(key, item);
420 }
421 }
422 serde_json::Value::String(text) if looks_sensitive && !text.is_empty() => {
423 *text = "***REDACTED***".to_string();
424 }
425 _ => {}
426 }
427}
428
429pub fn redacted_config_value(config: &Config) -> Result<serde_json::Value> {
430 let mut value = serde_json::to_value(config)?;
431 redact_sensitive_value("root", &mut value);
432 Ok(value)
433}
434
435pub fn print_json<T: serde::Serialize>(value: &T) -> Result<()> {
436 println!("{}", serde_json::to_string_pretty(value)?);
437 Ok(())
438}
439
440pub fn provider_status_report(config: &Config) -> ProviderStatusReport {
441 ProviderStatusReport {
442 current_model: config.agents.defaults.model.clone(),
443 current_provider: current_provider_name(config),
444 providers: provider_statuses(config),
445 }
446}
447
448pub fn resolve_provider_model_with_default(
449 config: &Config,
450 provider_name: &str,
451 provider_default_model: Option<&str>,
452 requested_model: Option<String>,
453) -> Result<String> {
454 if let Some(model) = requested_model {
455 return Ok(model);
456 }
457
458 if let Some(default_model) = provider_default_model.filter(|value| !value.trim().is_empty()) {
459 return Ok(default_model.to_string());
460 }
461
462 let current_provider = infer_provider_name_from_model(&config.agents.defaults.model)
463 .or_else(|| current_provider_name(config));
464 if current_provider.as_deref() == Some(provider_name) {
465 return Ok(config.agents.defaults.model.clone());
466 }
467
468 anyhow::bail!(
469 "Provider '{}' does not expose a default model in registry; pass --model explicitly",
470 provider_name
471 );
472}
473
474pub fn resolve_provider_model(
475 config: &Config,
476 provider_name: &str,
477 requested_model: Option<String>,
478) -> Result<String> {
479 resolve_provider_model_with_default(
480 config,
481 provider_name,
482 default_model_from_registry(provider_name).as_deref(),
483 requested_model,
484 )
485}
486
487pub fn provider_statuses(config: &Config) -> Vec<ProviderStatus> {
488 let catalog = ProviderCatalogService::new();
489 let active_provider = current_provider_name(config);
490
491 catalog
492 .list_provider_views(config)
493 .into_iter()
494 .map(|view| {
495 let current = active_provider.as_deref() == Some(view.id.as_str());
496 let missing_fields = if view.ready {
497 vec![]
498 } else if view
499 .api_base
500 .as_ref()
501 .map(|value| value.trim().is_empty())
502 .unwrap_or(true)
503 {
504 vec!["api_base".to_string()]
505 } else {
506 vec!["api_key".to_string()]
507 };
508 ProviderStatus {
509 name: view.id,
510 display_name: view.display_name,
511 default_model: view.default_model,
512 configurable: true,
513 configured: view.configured,
514 ready: view.ready,
515 uses_api_base: !missing_fields.iter().any(|field| field == "api_key"),
516 provider_for_default_model: current,
517 current,
518 model: current.then(|| config.agents.defaults.model.clone()),
519 api_base: view.api_base,
520 missing_fields,
521 }
522 })
523 .collect()
524}
525
526pub fn channel_statuses(config: &Config) -> Vec<ChannelStatus> {
527 vec![
528 ChannelStatus {
529 name: "telegram".to_string(),
530 enabled: config.channels.telegram.enabled,
531 ready: config.channels.telegram.enabled && !config.channels.telegram.token.is_empty(),
532 missing_fields: if config.channels.telegram.enabled
533 && config.channels.telegram.token.is_empty()
534 {
535 vec!["token".to_string()]
536 } else {
537 vec![]
538 },
539 notes: vec![],
540 },
541 ChannelStatus {
542 name: "discord".to_string(),
543 enabled: config.channels.discord.enabled,
544 ready: config.channels.discord.enabled && !config.channels.discord.token.is_empty(),
545 missing_fields: if config.channels.discord.enabled
546 && config.channels.discord.token.is_empty()
547 {
548 vec!["token".to_string()]
549 } else {
550 vec![]
551 },
552 notes: vec![],
553 },
554 ChannelStatus {
555 name: "whatsapp".to_string(),
556 enabled: config.channels.whatsapp.enabled,
557 ready: config.channels.whatsapp.enabled,
558 missing_fields: vec![],
559 notes: vec!["requires bridge login".to_string()],
560 },
561 ChannelStatus {
562 name: "feishu".to_string(),
563 enabled: config.channels.feishu.enabled,
564 ready: config.channels.feishu.enabled
565 && !config.channels.feishu.app_id.is_empty()
566 && !config.channels.feishu.app_secret.is_empty(),
567 missing_fields: [
568 ("app_id", config.channels.feishu.app_id.is_empty()),
569 ("app_secret", config.channels.feishu.app_secret.is_empty()),
570 ]
571 .into_iter()
572 .filter(|(_, missing)| config.channels.feishu.enabled && *missing)
573 .map(|(name, _)| name.to_string())
574 .collect(),
575 notes: vec![],
576 },
577 ChannelStatus {
578 name: "dingtalk".to_string(),
579 enabled: config.channels.dingtalk.enabled,
580 ready: config.channels.dingtalk.enabled
581 && !config.channels.dingtalk.client_id.is_empty()
582 && !config.channels.dingtalk.client_secret.is_empty(),
583 missing_fields: [
584 ("client_id", config.channels.dingtalk.client_id.is_empty()),
585 (
586 "client_secret",
587 config.channels.dingtalk.client_secret.is_empty(),
588 ),
589 ]
590 .into_iter()
591 .filter(|(_, missing)| config.channels.dingtalk.enabled && *missing)
592 .map(|(name, _)| name.to_string())
593 .collect(),
594 notes: vec![],
595 },
596 ChannelStatus {
597 name: "email".to_string(),
598 enabled: config.channels.email.enabled,
599 ready: config.channels.email.enabled
600 && !config.channels.email.imap_host.is_empty()
601 && !config.channels.email.imap_username.is_empty()
602 && !config.channels.email.imap_password.is_empty()
603 && !config.channels.email.smtp_host.is_empty()
604 && !config.channels.email.smtp_username.is_empty()
605 && !config.channels.email.smtp_password.is_empty()
606 && !config.channels.email.from_address.is_empty(),
607 missing_fields: [
608 ("imap_host", config.channels.email.imap_host.is_empty()),
609 (
610 "imap_username",
611 config.channels.email.imap_username.is_empty(),
612 ),
613 (
614 "imap_password",
615 config.channels.email.imap_password.is_empty(),
616 ),
617 ("smtp_host", config.channels.email.smtp_host.is_empty()),
618 (
619 "smtp_username",
620 config.channels.email.smtp_username.is_empty(),
621 ),
622 (
623 "smtp_password",
624 config.channels.email.smtp_password.is_empty(),
625 ),
626 (
627 "from_address",
628 config.channels.email.from_address.is_empty(),
629 ),
630 ]
631 .into_iter()
632 .filter(|(_, missing)| config.channels.email.enabled && *missing)
633 .map(|(name, _)| name.to_string())
634 .collect(),
635 notes: vec![],
636 },
637 ChannelStatus {
638 name: "slack".to_string(),
639 enabled: config.channels.slack.enabled,
640 ready: config.channels.slack.enabled
641 && !config.channels.slack.bot_token.is_empty()
642 && !config.channels.slack.app_token.is_empty(),
643 missing_fields: [
644 ("bot_token", config.channels.slack.bot_token.is_empty()),
645 ("app_token", config.channels.slack.app_token.is_empty()),
646 ]
647 .into_iter()
648 .filter(|(_, missing)| config.channels.slack.enabled && *missing)
649 .map(|(name, _)| name.to_string())
650 .collect(),
651 notes: vec![],
652 },
653 ChannelStatus {
654 name: "qq".to_string(),
655 enabled: config.channels.qq.enabled,
656 ready: config.channels.qq.enabled
657 && !config.channels.qq.app_id.is_empty()
658 && !config.channels.qq.secret.is_empty(),
659 missing_fields: [
660 ("app_id", config.channels.qq.app_id.is_empty()),
661 ("secret", config.channels.qq.secret.is_empty()),
662 ]
663 .into_iter()
664 .filter(|(_, missing)| config.channels.qq.enabled && *missing)
665 .map(|(name, _)| name.to_string())
666 .collect(),
667 notes: vec![],
668 },
669 ChannelStatus {
670 name: "matrix".to_string(),
671 enabled: config.channels.matrix.enabled,
672 ready: config.channels.matrix.enabled
673 && !config.channels.matrix.user_id.is_empty()
674 && !config.channels.matrix.access_token.is_empty(),
675 missing_fields: [
676 ("user_id", config.channels.matrix.user_id.is_empty()),
677 (
678 "access_token",
679 config.channels.matrix.access_token.is_empty(),
680 ),
681 ]
682 .into_iter()
683 .filter(|(_, missing)| config.channels.matrix.enabled && *missing)
684 .map(|(name, _)| name.to_string())
685 .collect(),
686 notes: vec![],
687 },
688 ]
689}
690
691pub fn doctor_report(runtime: &CliRuntime, config: &Config) -> DoctorReport {
692 let mut errors = Vec::new();
693 let mut warnings = Vec::new();
694
695 if let Err(err) = validate_config(config) {
696 errors.push(err.to_string());
697 }
698
699 let active_provider = current_provider_name(config);
700
701 if active_provider.is_none() {
702 errors.push(format!(
703 "No provider found for model '{}'",
704 config.agents.defaults.model
705 ));
706 } else if let Some(provider_name) = active_provider.as_deref() {
707 if let Some(provider_config) = provider_config_by_name(&config.providers, provider_name) {
708 let missing_key = provider_config.api_key.trim().is_empty();
709 let missing_api_base = provider_name == "custom"
710 && provider_config
711 .api_base
712 .as_ref()
713 .map(|base| base.trim().is_empty())
714 .unwrap_or(true);
715
716 if missing_key && provider_name != "vllm" {
717 warnings.push(format!(
718 "Provider '{}' is selected by the default model but api_key is empty",
719 provider_name
720 ));
721 }
722 if missing_api_base {
723 warnings.push("Provider 'custom' requires api_base".to_string());
724 }
725 }
726 }
727
728 let workspace = runtime.effective_workspace(config);
729 if !workspace.exists() {
730 warnings.push(format!(
731 "Workspace does not exist yet: {}",
732 workspace.display()
733 ));
734 }
735
736 let channels = channel_statuses(config);
737 for channel in &channels {
738 if channel.enabled && !channel.ready {
739 warnings.push(format!(
740 "Channel '{}' is enabled but missing fields: {}",
741 channel.name,
742 channel.missing_fields.join(", ")
743 ));
744 }
745 }
746
747 DoctorReport {
748 valid: errors.is_empty(),
749 ready: errors.is_empty() && warnings.is_empty(),
750 errors,
751 warnings,
752 provider: active_provider,
753 channels,
754 }
755}
756
757pub async fn collect_status_report(runtime: &CliRuntime) -> Result<StatusReport> {
758 let config = runtime.load_config()?;
759 let doctor = doctor_report(runtime, &config);
760 let cron_store = runtime.cron_store_path();
761 let cron_jobs = if cron_store.exists() {
762 let service = Arc::new(CronService::new(cron_store.clone(), None));
763 service.start().await;
764 let jobs = service.list_jobs(true).await.len();
765 service.stop().await;
766 jobs
767 } else {
768 0
769 };
770
771 Ok(StatusReport {
772 config: runtime.path_report(&config),
773 default_model: config.agents.defaults.model.clone(),
774 default_provider: doctor.provider.clone(),
775 logging: StatusLoggingReport {
776 level: config.logging.level.clone(),
777 format: config.logging.format.clone(),
778 dir: config.logging.dir.clone(),
779 },
780 providers: provider_statuses(&config),
781 channels: channel_statuses(&config),
782 cron_jobs,
783 mcp_servers: StatusMcpReport {
784 configured: config.tools.mcp_servers.len(),
785 disabled: config.tools.mcp_manager.disabled_servers.len(),
786 },
787 doctor: StatusDoctorSummary {
788 valid: doctor.valid,
789 ready: doctor.ready,
790 errors: doctor.errors,
791 warnings: doctor.warnings,
792 },
793 })
794}