Skip to main content

agent_diva_cli/
cli_runtime.rs

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}