1use axum::{
13 extract::{Query, State},
14 http::HeaderMap,
15 response::{IntoResponse, Response},
16};
17use serde::{Deserialize, Serialize};
18use zeroclaw_config::api_error::{ConfigApiCode, ConfigApiError};
19use zeroclaw_config::sections::Section;
20
21use super::AppState;
22use super::api::require_auth;
23
24#[derive(Debug, Serialize)]
25#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
26pub struct CatalogModelProvider {
27 pub name: String,
29 pub display_name: String,
31 pub local: bool,
33}
34
35#[derive(Debug, Serialize)]
36#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
37pub struct CatalogResponse {
38 pub model_providers: Vec<CatalogModelProvider>,
39}
40
41pub async fn handle_catalog(State(state): State<AppState>, headers: HeaderMap) -> Response {
45 if let Err(e) = require_auth(&state, &headers) {
46 return e.into_response();
47 }
48 let _ = state;
49
50 let model_providers: Vec<CatalogModelProvider> = zeroclaw_providers::list_model_providers()
51 .into_iter()
52 .map(|p| CatalogModelProvider {
53 name: p.name.to_string(),
54 display_name: p.display_name.to_string(),
55 local: p.local,
56 })
57 .collect();
58
59 axum::Json(CatalogResponse { model_providers }).into_response()
60}
61
62#[derive(Debug, Deserialize)]
63#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
64pub struct ModelsQuery {
65 #[serde(alias = "provider")]
68 pub model_provider: String,
69 #[serde(default)]
73 pub alias: Option<String>,
74}
75
76#[derive(Debug, Serialize)]
77#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
78pub struct ModelsResponse {
79 pub model_provider: String,
80 pub models: Vec<String>,
81 pub local: bool,
84 pub live: bool,
88}
89
90async fn catalog_models_for_config(
91 cfg: &zeroclaw_config::schema::Config,
92 model_provider: &str,
93 alias: Option<&str>,
94) -> ModelsResponse {
95 let alias = alias.map(str::trim).filter(|alias| !alias.is_empty());
96 let local = model_provider_family_is_local(model_provider);
97
98 let provider_path = if let Some(alias) = alias {
99 let Some(entry) = cfg.providers.models.find(model_provider, alias) else {
100 return ModelsResponse {
101 model_provider: model_provider.to_string(),
102 models: Vec::new(),
103 local,
104 live: false,
105 };
106 };
107 let api_key = entry.api_key.as_deref();
108 let options =
109 zeroclaw_providers::provider_runtime_options_for_alias(cfg, model_provider, alias);
110 let has_alias_endpoint = entry
111 .uri
112 .as_deref()
113 .map(str::trim)
114 .is_some_and(|uri| !uri.is_empty())
115 || options
116 .provider_api_url
117 .as_deref()
118 .map(str::trim)
119 .is_some_and(|uri| !uri.is_empty());
120 let alias_catalog_must_match_alias =
121 has_alias_endpoint || !model_provider_family_has_public_catalog(model_provider);
122 match zeroclaw_providers::create_model_provider_for_alias(
123 cfg,
124 model_provider,
125 alias,
126 api_key,
127 &options,
128 ) {
129 Ok(provider) => match provider.list_models().await {
130 Ok(models) => Some((models, true)),
131 Err(e) => {
132 record_catalog_models_error(model_provider, Some(alias), &e);
133 if alias_catalog_must_match_alias {
134 return ModelsResponse {
135 model_provider: model_provider.to_string(),
136 models: Vec::new(),
137 local,
138 live: false,
139 };
140 }
141 None
142 }
143 },
144 Err(e) => {
145 record_catalog_models_error(model_provider, Some(alias), &e);
146 if alias_catalog_must_match_alias {
147 return ModelsResponse {
148 model_provider: model_provider.to_string(),
149 models: Vec::new(),
150 local,
151 live: false,
152 };
153 }
154 None
155 }
156 }
157 } else {
158 match zeroclaw_providers::create_model_provider(model_provider, None) {
159 Ok(provider) => match provider.list_models().await {
160 Ok(models) => Some((models, true)),
161 Err(e) => {
162 record_catalog_models_error(model_provider, None, &e);
163 None
164 }
165 },
166 Err(e) => {
167 record_catalog_models_error(model_provider, None, &e);
168 None
169 }
170 }
171 };
172
173 let (models, live) = match provider_path {
174 Some((models, live)) => (models, live),
175 None => match zeroclaw_providers::catalog::list_models_for_family(model_provider).await {
176 Ok(models) => (models, true),
177 Err(e) => {
178 record_catalog_models_error(model_provider, alias, &e);
179 (Vec::new(), false)
180 }
181 },
182 };
183
184 ModelsResponse {
185 model_provider: model_provider.to_string(),
186 models,
187 local,
188 live,
189 }
190}
191
192fn model_provider_family_has_public_catalog(family: &str) -> bool {
193 match zeroclaw_providers::catalog::catalog_source_for(family) {
194 Some((models_dev_key, openrouter_vendor_prefix)) => {
195 models_dev_key.is_some() || openrouter_vendor_prefix.is_some()
196 }
197 None => false,
198 }
199}
200
201fn record_catalog_models_error(model_provider: &str, alias: Option<&str>, error: &anyhow::Error) {
202 ::zeroclaw_log::record!(
203 DEBUG,
204 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
205 ::serde_json::json!({
206 "model_provider": model_provider,
207 "alias": alias,
208 "error": format!("{}", error),
209 })
210 ),
211 "model catalog fetch failed"
212 );
213}
214
215pub async fn handle_catalog_models(
225 State(state): State<AppState>,
226 headers: HeaderMap,
227 Query(q): Query<ModelsQuery>,
228) -> Response {
229 if let Err(e) = require_auth(&state, &headers) {
230 return e.into_response();
231 }
232 let cfg = state.config.read().clone();
233 axum::Json(catalog_models_for_config(&cfg, &q.model_provider, q.alias.as_deref()).await)
234 .into_response()
235}
236
237fn error_response(err: ConfigApiError) -> Response {
238 let status = axum::http::StatusCode::from_u16(err.code.http_status())
239 .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
240 (status, axum::Json(err)).into_response()
241}
242
243#[derive(Debug, Serialize)]
246#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
247pub struct SectionInfo {
248 pub key: String,
252 pub label: String,
254 pub help: String,
256 pub has_picker: bool,
260 pub completed: bool,
263 pub ready: bool,
268 pub group: String,
272 pub is_onboarding: bool,
279 #[serde(skip_serializing_if = "Option::is_none")]
286 pub shape: Option<zeroclaw_config::sections::SectionShape>,
287}
288
289#[derive(Debug, Serialize)]
290#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
291pub struct SectionsResponse {
292 pub sections: Vec<SectionInfo>,
293}
294
295#[derive(Debug, Serialize)]
296#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
297pub struct OnboardRepairItem {
298 pub code: &'static str,
301 pub message: String,
303 pub section: &'static str,
305 #[serde(skip_serializing_if = "Option::is_none")]
307 pub focus: Option<String>,
308}
309
310#[derive(Debug, Serialize)]
311#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
312pub struct OnboardStatusResponse {
313 pub needs_onboarding: bool,
316 pub reason: &'static str,
320 pub has_partial_state: bool,
324 pub missing: Vec<String>,
327 pub repair_items: Vec<OnboardRepairItem>,
330}
331
332#[must_use]
339pub fn derive_onboard_status(cfg: &zeroclaw_config::schema::Config) -> OnboardStatusResponse {
340 let repair_items = onboard_repair_items(cfg);
341 let missing: Vec<String> = repair_items
342 .iter()
343 .map(|item| item.message.clone())
344 .collect();
345 let ready = repair_items.is_empty();
346 let has_partial_state = !cfg.onboard_state.completed_sections.is_empty()
347 || cfg.providers.models.iter_entries().next().is_some()
348 || !cfg.risk_profiles.is_empty()
349 || !cfg.runtime_profiles.is_empty()
350 || !cfg.agents.is_empty();
351 let reason = if ready {
352 "has_dispatchable_agent"
353 } else if has_partial_state {
354 "incomplete_agent"
355 } else {
356 "fresh_install"
357 };
358 OnboardStatusResponse {
359 needs_onboarding: !ready,
360 reason,
361 has_partial_state,
362 missing,
363 repair_items,
364 }
365}
366
367fn onboard_repair_items(cfg: &zeroclaw_config::schema::Config) -> Vec<OnboardRepairItem> {
368 let mut items = Vec::new();
369 if cfg.providers.models.iter_entries().next().is_none() {
370 items.push(repair_item(
371 "model_provider_missing",
372 "Add a model provider.",
373 Section::ModelProviders,
374 None,
375 ));
376 }
377 if cfg.agents.is_empty() {
378 items.push(repair_item(
379 "agent_missing",
380 "Create an agent.",
381 Section::Agents,
382 None,
383 ));
384 return items;
385 }
386
387 let mut agent_aliases: Vec<&String> = cfg.agents.keys().collect();
388 agent_aliases.sort();
389 if agent_aliases
390 .iter()
391 .any(|alias| onboard_agent_is_dispatchable(cfg, alias, &cfg.agents[*alias]))
392 {
393 return Vec::new();
394 }
395 for alias in agent_aliases {
396 items.extend(onboard_agent_repair_items(cfg, alias, &cfg.agents[alias]));
397 }
398 items
399}
400
401fn onboard_agent_repair_items(
402 cfg: &zeroclaw_config::schema::Config,
403 alias: &str,
404 agent: &zeroclaw_config::schema::AliasedAgentConfig,
405) -> Vec<OnboardRepairItem> {
406 let agent_focus = Some(format!("agents.{alias}"));
407 let mut items = Vec::new();
408 if !agent.enabled {
409 items.push(repair_item(
410 "agent_disabled",
411 format!("Enable agent `{alias}`."),
412 Section::Agents,
413 agent_focus.clone(),
414 ));
415 }
416
417 let model_ref = agent.model_provider.trim();
418 if model_ref.is_empty() {
419 items.push(repair_item(
420 "agent_model_provider_missing",
421 format!("Set a model provider for agent `{alias}`."),
422 Section::Agents,
423 agent_focus.clone(),
424 ));
425 } else if let Some((family, provider_alias, provider)) =
426 cfg.resolved_model_provider_for_agent(alias)
427 {
428 let has_model = provider
429 .model
430 .as_deref()
431 .map(str::trim)
432 .is_some_and(|m| !m.is_empty());
433 let provider_focus = model_provider_focus(family, provider_alias);
434 if !has_model {
435 items.push(repair_item(
436 "model_provider_model_missing",
437 format!("Choose a model for model provider `{model_ref}`."),
438 Section::ModelProviders,
439 provider_focus,
440 ));
441 } else if !model_provider_alias_usable(provider, model_provider_family_is_local(family)) {
442 items.push(repair_item(
443 "model_provider_auth_missing",
444 format!("Set credential/auth for model provider `{model_ref}`."),
445 Section::ModelProviders,
446 provider_focus,
447 ));
448 }
449 } else {
450 items.push(repair_item(
451 "agent_model_provider_unresolved",
452 format!(
453 "Fix agent `{alias}` model provider `{model_ref}`; it does not resolve to a configured provider."
454 ),
455 Section::Agents,
456 agent_focus.clone(),
457 ));
458 }
459
460 let risk_ref = agent.risk_profile.trim();
461 if risk_ref.is_empty() {
462 items.push(repair_item(
463 "agent_risk_profile_missing",
464 format!("Set a risk profile for agent `{alias}`."),
465 Section::Agents,
466 agent_focus.clone(),
467 ));
468 } else if !cfg.risk_profiles.contains_key(risk_ref) {
469 items.push(repair_item(
470 "agent_risk_profile_unresolved",
471 format!(
472 "Fix agent `{alias}` risk profile `{risk_ref}`; it does not resolve to a configured profile."
473 ),
474 Section::Agents,
475 agent_focus.clone(),
476 ));
477 }
478
479 let runtime_ref = agent.runtime_profile.trim();
480 if runtime_ref.is_empty() {
481 items.push(repair_item(
482 "agent_runtime_profile_missing",
483 format!("Set a runtime profile for agent `{alias}`."),
484 Section::Agents,
485 agent_focus,
486 ));
487 } else if !cfg.runtime_profiles.contains_key(runtime_ref) {
488 items.push(repair_item(
489 "agent_runtime_profile_unresolved",
490 format!(
491 "Fix agent `{alias}` runtime profile `{runtime_ref}`; it does not resolve to a configured profile."
492 ),
493 Section::Agents,
494 agent_focus,
495 ));
496 }
497
498 items
499}
500
501fn repair_item(
502 code: &'static str,
503 message: impl Into<String>,
504 section: Section,
505 focus: Option<String>,
506) -> OnboardRepairItem {
507 OnboardRepairItem {
508 code,
509 message: message.into(),
510 section: section.as_str(),
511 focus,
512 }
513}
514
515fn model_provider_focus(family: &str, alias: &str) -> Option<String> {
516 if alias.trim().is_empty() {
517 return None;
518 }
519 let section = Section::ModelProviders;
520 let config_family = typed_family_config_key(section, family);
521 let section_key = section.as_str();
522 Some(format!("{section_key}.{config_family}.{alias}"))
523}
524
525fn onboard_agent_is_dispatchable(
526 cfg: &zeroclaw_config::schema::Config,
527 alias: &str,
528 agent: &zeroclaw_config::schema::AliasedAgentConfig,
529) -> bool {
530 if !agent.enabled {
531 return false;
532 }
533 let model_ref = agent.model_provider.trim();
534 if model_ref.is_empty() {
535 return false;
536 }
537 let Some((family, _, provider)) = cfg.resolved_model_provider_for_agent(alias) else {
538 return false;
539 };
540 let has_model = provider
541 .model
542 .as_deref()
543 .map(str::trim)
544 .is_some_and(|m| !m.is_empty());
545 if !has_model || !model_provider_alias_usable(provider, model_provider_family_is_local(family))
546 {
547 return false;
548 }
549 let risk_ref = agent.risk_profile.trim();
550 if risk_ref.is_empty() || !cfg.risk_profiles.contains_key(risk_ref) {
551 return false;
552 }
553 let runtime_ref = agent.runtime_profile.trim();
554 if runtime_ref.is_empty() || !cfg.runtime_profiles.contains_key(runtime_ref) {
555 return false;
556 }
557 true
558}
559
560pub async fn handle_onboard_status(State(state): State<AppState>, headers: HeaderMap) -> Response {
566 if let Err(e) = require_auth(&state, &headers) {
567 return e.into_response();
568 }
569 let cfg = state.config.read().clone();
570 axum::Json(derive_onboard_status(&cfg)).into_response()
571}
572
573#[derive(Debug, Serialize)]
578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
579pub struct AgentOptionsResponse {
580 pub channels: Vec<String>,
581 pub channel_types: Vec<String>,
584 pub model_providers: Vec<String>,
585 pub risk_profiles: Vec<String>,
586 pub runtime_profiles: Vec<String>,
587 pub skill_bundles: Vec<String>,
588 pub knowledge_bundles: Vec<String>,
589 pub mcp_bundles: Vec<String>,
590 pub agents: Vec<String>,
591}
592
593pub fn build_agent_options(cfg: &zeroclaw_config::schema::Config) -> AgentOptionsResponse {
603 fn dotted_aliases(cfg: &zeroclaw_config::schema::Config, prefix: &str) -> Vec<String> {
604 let mut out: Vec<String> = Vec::new();
605 for f in cfg.prop_fields() {
606 if let Some(rest) = f.name.strip_prefix(&format!("{prefix}.")) {
607 let mut parts = rest.splitn(3, '.');
608 if let (Some(ty), Some(alias), Some(_)) = (parts.next(), parts.next(), parts.next())
609 {
610 let dotted = format!("{ty}.{alias}");
611 if !out.contains(&dotted) {
612 out.push(dotted);
613 }
614 }
615 }
616 }
617 out.sort();
618 out
619 }
620
621 let channels = dotted_aliases(cfg, "channels");
622 let mut channel_types: Vec<String> = channels
623 .iter()
624 .filter_map(|d| d.split_once('.').map(|(t, _)| t.to_string()))
625 .collect();
626 channel_types.sort();
627 channel_types.dedup();
628
629 AgentOptionsResponse {
630 channels,
631 channel_types,
632 model_providers: dotted_aliases(cfg, "providers.models"),
633 risk_profiles: cfg.get_map_keys("risk-profiles").unwrap_or_default(),
634 runtime_profiles: cfg.get_map_keys("runtime-profiles").unwrap_or_default(),
635 skill_bundles: cfg.get_map_keys("skill-bundles").unwrap_or_default(),
636 knowledge_bundles: cfg.get_map_keys("knowledge-bundles").unwrap_or_default(),
637 mcp_bundles: cfg.get_map_keys("mcp-bundles").unwrap_or_default(),
638 agents: cfg.get_map_keys("agents").unwrap_or_default(),
639 }
640}
641
642pub async fn handle_agent_options(State(state): State<AppState>, headers: HeaderMap) -> Response {
646 if let Err(e) = require_auth(&state, &headers) {
647 return e.into_response();
648 }
649 let cfg = state.config.read().clone();
650 axum::Json(build_agent_options(&cfg)).into_response()
651}
652
653pub async fn handle_sections(State(state): State<AppState>, headers: HeaderMap) -> Response {
663 if let Err(e) = require_auth(&state, &headers) {
664 return e.into_response();
665 }
666 let cfg = state.config.read().clone();
667 let completed: std::collections::HashSet<String> = cfg
668 .onboard_state
669 .completed_sections
670 .iter()
671 .cloned()
672 .collect();
673
674 let mut roots: std::collections::BTreeSet<String> = cfg
677 .prop_fields()
678 .iter()
679 .filter_map(|f| f.name.split('.').next().map(str::to_string))
680 .collect();
681
682 for hidden in HIDDEN_TOP_LEVEL {
684 roots.remove(*hidden);
685 }
686
687 let all_map_paths: Vec<&'static str> = zeroclaw_config::schema::Config::map_key_sections()
694 .iter()
695 .map(|s| s.path)
696 .collect();
697 let section_has_picker_for_key = |key: &str| -> bool {
698 let key_dot = format!("{key}.");
699 all_map_paths.iter().any(|p| {
700 *p == key
701 || p.strip_prefix(&key_dot)
702 .is_some_and(|rest| !rest.contains('.'))
703 })
704 };
705
706 let map_keyed_roots: std::collections::HashSet<&'static str> = all_map_paths
711 .iter()
712 .filter_map(|p| p.split('.').next())
713 .collect();
714 for &prefix in &map_keyed_roots {
715 roots.insert(prefix.to_string());
716 }
717
718 for s in zeroclaw_config::sections::ONBOARDING_SECTIONS {
723 roots.insert(s.as_str().to_string());
724 }
725
726 let prefixes_with_children: std::collections::HashSet<String> = roots
729 .iter()
730 .filter_map(|k| k.split_once('.').map(|(parent, _)| parent.to_string()))
731 .collect();
732 roots.retain(|k| k.contains('.') || !prefixes_with_children.contains(k));
733
734 roots.retain(|k| !k.starts_with("cost.rates"));
741
742 let mut ordered: Vec<String> = roots.into_iter().collect();
748 ordered.sort_by(|a, b| {
749 match (
750 zeroclaw_config::sections::section_index_for_key(a),
751 zeroclaw_config::sections::section_index_for_key(b),
752 ) {
753 (Some(ai), Some(bi)) => ai.cmp(&bi),
754 (Some(_), None) => std::cmp::Ordering::Less,
755 (None, Some(_)) => std::cmp::Ordering::Greater,
756 (None, None) => a.cmp(b),
757 }
758 });
759
760 let sections: Vec<SectionInfo> = ordered
761 .into_iter()
762 .map(|key| {
763 let wizard = zeroclaw_config::sections::Section::from_key(&key);
769 let has_picker = match wizard {
770 Some(w) => !matches!(
771 w,
772 zeroclaw_config::sections::Section::Hardware
773 | zeroclaw_config::sections::Section::Mcp
774 | zeroclaw_config::sections::Section::Skills
775 ),
776 None => section_has_picker_for_key(&key),
777 };
778 SectionInfo {
779 completed: completed.contains(&key),
780 ready: section_ready(&cfg, &key, completed.contains(&key)),
781 label: humanize_section(&key),
782 help: section_help(&key).to_string(),
783 has_picker,
784 group: section_group(&key).to_string(),
785 is_onboarding: wizard.is_some(),
786 shape: wizard.map(zeroclaw_config::sections::Section::shape),
787 key,
788 }
789 })
790 .collect();
791
792 axum::Json(SectionsResponse { sections }).into_response()
793}
794
795fn section_ready(cfg: &zeroclaw_config::schema::Config, key: &str, completed_marker: bool) -> bool {
796 use zeroclaw_config::sections::Section;
797 match Section::from_key(key) {
798 Some(Section::ModelProviders) => any_usable_model_provider(cfg),
799 Some(Section::RiskProfiles) => !cfg.risk_profiles.is_empty(),
800 Some(Section::RuntimeProfiles) => !cfg.runtime_profiles.is_empty(),
801 Some(Section::Storage) => cfg
802 .prop_fields()
803 .iter()
804 .any(|field| field.name.starts_with("storage.")),
805 Some(Section::Memory) => completed_marker,
806 Some(Section::Agents) => cfg
807 .agents
808 .iter()
809 .any(|(alias, agent)| onboard_agent_is_dispatchable(cfg, alias, agent)),
810 _ => completed_marker,
811 }
812}
813
814const HIDDEN_TOP_LEVEL: &[&str] = &[
817 "schema_version",
818 "onboard_state",
819 "onboard-state",
820 "config_path",
821 "workspace_dir",
822 "env_overridden_paths",
823 "pre_override_snapshots",
824];
825
826fn humanize_section(key: &str) -> String {
830 match key {
831 "providers.models" => return "Model providers".to_string(),
832 "providers.tts" => return "TTS providers".to_string(),
833 "providers.transcription" => return "Transcription providers".to_string(),
834 _ => {}
835 }
836 let mut s = key.replace(['_', '-'], " ");
837 if let Some(c) = s.get_mut(0..1) {
838 c.make_ascii_uppercase();
839 }
840 s
841}
842
843fn section_group(key: &str) -> &'static str {
851 match key {
852 "providers.models" | "channels" | "memory" | "hardware" | "tunnel" | "agents"
853 | "skills" | "skill-bundles" | "risk-profiles" | "runtime-profiles" | "peer-groups" => {
854 "Foundation"
855 }
856 "agent"
858 | "cron"
859 | "heartbeat"
860 | "hooks"
861 | "pacing"
862 | "pipeline"
863 | "query_classification"
864 | "reliability"
865 | "runtime"
866 | "scheduler"
867 | "sop"
868 | "verifiable_intent" => "Agent",
869 "delegate" => "Multi-agent",
871 "browser" | "browser_delegate" | "http_request" | "image_gen" | "knowledge"
873 | "link_enricher" | "mcp" | "media_pipeline" | "multimodal" | "plugins"
874 | "project_intel" | "shell_tool" | "text_browser" | "transcription" | "tts"
875 | "web_fetch" | "web_search" => "Tools",
876 "acp" | "claude_code" | "claude_code_runner" | "codex_cli" | "composio" | "gemini_cli"
879 | "google_workspace" | "jira" | "linkedin" | "notion" | "opencode_cli" => "Integrations",
880 "gateway" | "node_transport" | "nodes" | "proxy" => "Network",
882 "identity" | "secrets" | "storage" => "Storage",
884 "backup" | "cloud_ops" | "conversational_ai" | "cost" | "data_retention"
886 | "observability" | "peripherals" | "security" | "security_ops" | "trust" => "Operations",
887 _ => "Other",
888 }
889}
890
891fn section_help(key: &str) -> &'static str {
896 zeroclaw_config::sections::section_help(key)
897}
898
899#[derive(Debug, Deserialize)]
900#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
901pub struct SectionPath {
902 pub section: String,
903}
904
905#[derive(Debug, Serialize)]
906#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
907pub struct PickerItem {
908 pub key: String,
910 pub label: String,
913 #[serde(skip_serializing_if = "Option::is_none")]
916 pub description: Option<String>,
917 #[serde(skip_serializing_if = "Option::is_none")]
921 pub badge: Option<String>,
922}
923
924#[derive(Debug, Serialize)]
925#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
926pub struct PickerResponse {
927 pub section: String,
928 pub items: Vec<PickerItem>,
929 pub help: String,
932}
933
934pub async fn handle_section_picker(
945 State(state): State<AppState>,
946 headers: HeaderMap,
947 axum::extract::Path(SectionPath { section }): axum::extract::Path<SectionPath>,
948) -> Response {
949 if let Err(e) = require_auth(&state, &headers) {
950 return e.into_response();
951 }
952 let cfg = state.config.read().clone();
953
954 use zeroclaw_config::sections::Section;
955 let Some(section_enum) = Section::from_key(§ion) else {
956 return error_response(
957 ConfigApiError::new(
958 ConfigApiCode::PathNotFound,
959 format!(
960 "section `{section}` has no picker; render its fields \
961 via GET /api/config/list?prefix={section}"
962 ),
963 )
964 .with_path(section.as_str()),
965 );
966 };
967 let help = section_help(section_enum.as_str()).to_string();
968 let items = match picker_items_for(section_enum, &cfg) {
969 PickerDispatch::Items(items) => items,
970 PickerDispatch::DirectForm => {
971 return error_response(
972 ConfigApiError::new(
973 ConfigApiCode::PathNotFound,
974 format!(
975 "section `{section_enum}` is a direct-form section with no picker; \
976 render fields via GET /api/config/list?prefix={section_enum}"
977 ),
978 )
979 .with_path(section_enum.as_str()),
980 );
981 }
982 };
983
984 axum::Json(PickerResponse {
985 section,
986 items,
987 help,
988 })
989 .into_response()
990}
991
992enum PickerDispatch {
1001 Items(Vec<PickerItem>),
1002 DirectForm,
1003}
1004
1005fn picker_items_for(
1009 section: zeroclaw_config::sections::Section,
1010 cfg: &zeroclaw_config::schema::Config,
1011) -> PickerDispatch {
1012 use zeroclaw_config::sections::Section;
1013 match section {
1014 Section::ModelProviders => PickerDispatch::Items(providers_picker(cfg)),
1015 Section::TtsProviders | Section::TranscriptionProviders => {
1020 PickerDispatch::Items(schema_walk_picker(cfg, section.as_str()))
1021 }
1022 Section::Memory => PickerDispatch::Items(memory_picker(cfg)),
1023 Section::Channels => PickerDispatch::Items(schema_walk_picker(cfg, "channels")),
1024 Section::Tunnel => PickerDispatch::Items(schema_walk_picker_with_none(
1025 cfg,
1026 "tunnel",
1027 "tunnel.tunnel-provider",
1028 )),
1029 Section::Agents => PickerDispatch::Items(agents_picker(cfg)),
1030 Section::Storage => PickerDispatch::Items(storage_picker(cfg)),
1033 Section::PeerGroups
1037 | Section::Cron
1038 | Section::McpBundles
1039 | Section::KnowledgeBundles
1040 | Section::SkillBundles
1041 | Section::RiskProfiles
1042 | Section::RuntimeProfiles => {
1043 PickerDispatch::Items(one_tier_alias_map_picker(cfg, section.as_str()))
1044 }
1045 Section::Hardware | Section::Mcp | Section::Skills => PickerDispatch::DirectForm,
1046 }
1047}
1048
1049fn providers_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
1050 zeroclaw_providers::list_model_providers()
1051 .into_iter()
1052 .map(|p| PickerItem {
1053 key: p.name.to_string(),
1054 label: p.display_name.to_string(),
1055 description: if p.local {
1056 Some("Local — no API key required".to_string())
1057 } else {
1058 None
1059 },
1060 badge: provider_type_badge(cfg, p.name, p.local),
1061 })
1062 .collect()
1063}
1064
1065fn any_usable_model_provider(cfg: &zeroclaw_config::schema::Config) -> bool {
1066 cfg.providers
1067 .models
1068 .iter_entries()
1069 .any(|(family, _, base)| {
1070 model_provider_alias_usable(base, model_provider_family_is_local(family))
1071 })
1072}
1073
1074fn model_provider_family_is_local(family: &str) -> bool {
1075 zeroclaw_providers::list_model_providers()
1076 .iter()
1077 .find(|provider| provider.name == family)
1078 .is_some_and(|provider| provider.local)
1079}
1080
1081fn provider_type_badge(
1082 cfg: &zeroclaw_config::schema::Config,
1083 family: &str,
1084 local: bool,
1085) -> Option<String> {
1086 let mut has_alias = false;
1087 let mut has_usable_alias = false;
1088 for (ty, _, base) in cfg.providers.models.iter_entries() {
1089 if ty != family {
1090 continue;
1091 }
1092 has_alias = true;
1093 if model_provider_alias_usable(base, local) {
1094 has_usable_alias = true;
1095 }
1096 }
1097 if has_usable_alias {
1098 Some("configured".to_string())
1099 } else if has_alias {
1100 Some("needs setup".to_string())
1101 } else {
1102 None
1103 }
1104}
1105
1106fn model_provider_alias_usable(
1107 base: &zeroclaw_config::schema::ModelProviderConfig,
1108 local: bool,
1109) -> bool {
1110 let has_model = base
1111 .model
1112 .as_deref()
1113 .map(str::trim)
1114 .is_some_and(|model| !model.is_empty());
1115 if !has_model {
1116 return false;
1117 }
1118 base.api_key
1119 .as_deref()
1120 .map(str::trim)
1121 .is_some_and(|key| !key.is_empty())
1122 || base.requires_openai_auth
1123 || local
1124}
1125
1126fn storage_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
1127 let mut items = schema_walk_picker(cfg, "storage");
1128 for item in &mut items {
1129 item.description = storage_description(&item.key).map(str::to_string);
1130 if item.badge.as_deref() == Some("configured") {
1131 item.badge = Some("created".to_string());
1132 }
1133 }
1134 items.sort_by_key(|item| storage_rank(&item.key));
1135 items
1136}
1137
1138fn storage_rank(key: &str) -> usize {
1139 match key {
1140 "sqlite" => 0,
1141 "postgres" => 1,
1142 "qdrant" => 2,
1143 "markdown" => 3,
1144 "lucid" => 4,
1145 _ => 99,
1146 }
1147}
1148
1149fn storage_description(key: &str) -> Option<&'static str> {
1150 match key {
1151 "sqlite" => Some(
1152 "Safe default for single-node installs: file-based, zero-config, no external service.",
1153 ),
1154 "postgres" => {
1155 Some("Shared or multi-instance deployments that need durable server-backed storage.")
1156 }
1157 "qdrant" => {
1158 Some("Vector database backend for semantic search when you already run Qdrant.")
1159 }
1160 "markdown" => {
1161 Some("Human-readable files with simple local storage and no database service.")
1162 }
1163 "lucid" => {
1164 Some("Bridge to local lucid-memory CLI while keeping SQLite-style local operation.")
1165 }
1166 _ => None,
1167 }
1168}
1169
1170fn memory_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
1171 let current = cfg.memory.backend.clone();
1172 let memory_completed = cfg
1173 .onboard_state
1174 .completed_sections
1175 .iter()
1176 .any(|section| section == "memory");
1177 zeroclaw_memory::selectable_memory_backends()
1178 .iter()
1179 .map(|b| PickerItem {
1180 key: b.key.to_string(),
1181 label: b.label.to_string(),
1182 description: None,
1183 badge: if b.key == current && memory_completed {
1184 Some("active".to_string())
1185 } else {
1186 None
1187 },
1188 })
1189 .collect()
1190}
1191
1192fn schema_walk_picker(cfg: &zeroclaw_config::schema::Config, section: &str) -> Vec<PickerItem> {
1198 let prefix_with_dot = format!("{section}.");
1199
1200 let configured: std::collections::BTreeSet<String> = cfg
1202 .prop_fields()
1203 .iter()
1204 .filter_map(|f| f.name.strip_prefix(&prefix_with_dot))
1205 .filter_map(|suffix| suffix.split_once('.').map(|(head, _)| head.to_string()))
1206 .collect();
1207
1208 let all: std::collections::BTreeSet<String> =
1211 zeroclaw_config::schema::Config::map_key_sections()
1212 .into_iter()
1213 .filter_map(|s| {
1214 s.path
1215 .strip_prefix(&prefix_with_dot)
1216 .filter(|rest| !rest.contains('.'))
1217 .map(String::from)
1218 })
1219 .collect();
1220
1221 all.into_iter()
1222 .map(|name| {
1223 let badge = if configured.contains(&name) {
1227 Some("configured".to_string())
1228 } else {
1229 None
1230 };
1231 PickerItem {
1232 key: name.clone(),
1233 label: name.clone(),
1234 description: None,
1235 badge,
1236 }
1237 })
1238 .collect()
1239}
1240
1241fn one_tier_alias_map_picker(
1251 cfg: &zeroclaw_config::schema::Config,
1252 section: &str,
1253) -> Vec<PickerItem> {
1254 let prefix_with_dot = format!("{section}.");
1255 let mut keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1256 for field in cfg.prop_fields() {
1257 let Some(suffix) = field.name.strip_prefix(&prefix_with_dot) else {
1258 continue;
1259 };
1260 let head = suffix.split_once('.').map_or(suffix, |(h, _)| h);
1261 if head.is_empty() {
1262 continue;
1263 }
1264 keys.insert(head.to_string());
1265 }
1266 keys.into_iter()
1267 .map(|key| PickerItem {
1268 key: key.clone(),
1269 label: key,
1270 description: None,
1271 badge: Some("configured".to_string()),
1272 })
1273 .collect()
1274}
1275
1276fn agents_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
1279 let mut items: Vec<PickerItem> = cfg
1280 .agents
1281 .iter()
1282 .map(|(alias, agent)| PickerItem {
1283 key: alias.clone(),
1284 label: alias.clone(),
1285 description: None,
1286 badge: if agent.enabled {
1287 Some("active".to_string())
1288 } else {
1289 Some("configured".to_string())
1290 },
1291 })
1292 .collect();
1293 items.sort_by(|a, b| a.key.cmp(&b.key));
1294 items
1295}
1296
1297fn apply_first_run_agent_defaults(cfg: &mut zeroclaw_config::schema::Config, alias: &str) {
1298 let model_provider = cfg.first_model_provider_alias();
1299 let risk_profile = first_alias_preferring_default(cfg.risk_profiles.keys());
1300 let runtime_profile = first_alias_preferring_default(cfg.runtime_profiles.keys());
1301
1302 let Some(agent) = cfg.agents.get_mut(alias) else {
1303 return;
1304 };
1305 if agent.model_provider.trim().is_empty()
1306 && let Some(model_provider) = model_provider
1307 {
1308 agent.model_provider = model_provider.into();
1309 }
1310 if agent.risk_profile.trim().is_empty()
1311 && let Some(risk_profile) = risk_profile
1312 {
1313 agent.risk_profile = risk_profile;
1314 }
1315 if agent.runtime_profile.trim().is_empty()
1316 && let Some(runtime_profile) = runtime_profile
1317 {
1318 agent.runtime_profile = runtime_profile;
1319 }
1320}
1321
1322fn mark_onboard_section_completed(cfg: &mut zeroclaw_config::schema::Config, section: &str) {
1323 if !cfg
1324 .onboard_state
1325 .completed_sections
1326 .iter()
1327 .any(|completed| completed == section)
1328 {
1329 cfg.onboard_state
1330 .completed_sections
1331 .push(section.to_string());
1332 cfg.mark_dirty("onboard-state.completed-sections");
1333 }
1334}
1335
1336fn first_alias_preferring_default<'a>(aliases: impl Iterator<Item = &'a String>) -> Option<String> {
1337 let mut aliases: Vec<&String> = aliases.collect();
1338 aliases.sort();
1339 aliases
1340 .iter()
1341 .find(|alias| alias.as_str() == "default")
1342 .or_else(|| aliases.first())
1343 .map(|alias| (*alias).clone())
1344}
1345
1346fn schema_walk_picker_with_none(
1350 cfg: &zeroclaw_config::schema::Config,
1351 section: &str,
1352 active_prop_path: &str,
1353) -> Vec<PickerItem> {
1354 let active = cfg.get_prop(active_prop_path).unwrap_or_default();
1355 let mut items = vec![PickerItem {
1356 key: "none".to_string(),
1357 label: "none".to_string(),
1358 description: Some("Localhost only — no public tunnel.".to_string()),
1359 badge: if active == "none" || active.is_empty() {
1360 Some("active".to_string())
1361 } else {
1362 None
1363 },
1364 }];
1365 let mut rest = schema_walk_picker(cfg, section);
1366 for item in &mut rest {
1368 if item.key == active {
1369 item.badge = Some("active".to_string());
1370 }
1371 }
1372 items.extend(rest);
1373 items
1374}
1375
1376#[derive(Debug, Serialize)]
1377#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1378pub struct SelectItemResponse {
1379 pub fields_prefix: String,
1383 pub created: bool,
1385}
1386
1387#[derive(Debug, Deserialize)]
1388#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1389pub struct SectionItemPath {
1390 pub section: String,
1391 pub key: String,
1392}
1393
1394#[derive(Debug, Default, Deserialize)]
1410#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1411pub struct SectionSelectBody {
1412 pub alias: Option<String>,
1413}
1414
1415fn typed_family_config_key(section: zeroclaw_config::sections::Section, key: &str) -> String {
1416 if matches!(
1417 section,
1418 zeroclaw_config::sections::Section::ModelProviders
1419 | zeroclaw_config::sections::Section::TtsProviders
1420 | zeroclaw_config::sections::Section::TranscriptionProviders
1421 ) {
1422 key.replace('_', "-")
1428 } else {
1429 key.to_string()
1430 }
1431}
1432
1433pub async fn handle_section_select(
1434 State(state): State<AppState>,
1435 headers: HeaderMap,
1436 axum::extract::Path(SectionItemPath { section, key }): axum::extract::Path<SectionItemPath>,
1437 body: Option<axum::extract::Json<SectionSelectBody>>,
1438) -> Response {
1439 if let Err(e) = require_auth(&state, &headers) {
1440 return e.into_response();
1441 }
1442
1443 let alias = body
1444 .and_then(|b| b.0.alias)
1445 .map(|s| s.trim().to_string())
1446 .filter(|s| !s.is_empty())
1447 .unwrap_or_else(|| "default".to_string());
1448
1449 let mut working = state.config.read().clone();
1450
1451 use zeroclaw_config::sections::Section;
1452 let Some(section_enum) = Section::from_key(§ion) else {
1453 return error_response(
1454 ConfigApiError::new(
1455 ConfigApiCode::PathNotFound,
1456 format!("no picker semantics defined for section `{section}`"),
1457 )
1458 .with_path(section.as_str()),
1459 );
1460 };
1461
1462 let (fields_prefix, created) = match section_enum {
1463 Section::ModelProviders | Section::TtsProviders | Section::TranscriptionProviders => {
1464 let family = section_enum.as_str();
1470 let config_key = typed_family_config_key(section_enum, &key);
1471 let created = working
1472 .create_map_key(&format!("{family}.{config_key}"), &alias)
1473 .map_err(|msg| {
1474 error_response(
1475 ConfigApiError::new(
1476 ConfigApiCode::PathNotFound,
1477 format!("could not select {family} `{key}` alias `{alias}`: {msg}"),
1478 )
1479 .with_path(format!("{family}.{config_key}")),
1480 )
1481 });
1482 let created = match created {
1483 Ok(c) => c,
1484 Err(resp) => return resp,
1485 };
1486 (format!("{family}.{config_key}.{alias}"), created)
1489 }
1490 Section::Channels => {
1491 let created = working
1492 .create_map_key(&format!("channels.{key}"), &alias)
1493 .map_err(|msg| {
1494 error_response(
1495 ConfigApiError::new(
1496 ConfigApiCode::PathNotFound,
1497 format!("could not select channel `{key}` alias `{alias}`: {msg}"),
1498 )
1499 .with_path(format!("channels.{key}")),
1500 )
1501 });
1502 let created = match created {
1503 Ok(c) => c,
1504 Err(resp) => return resp,
1505 };
1506 if created {
1514 let enabled_path = format!("channels.{key}.{alias}.enabled");
1515 if let Err(e) = working.set_prop_persistent(&enabled_path, "true") {
1516 ::zeroclaw_log::record!(
1517 WARN,
1518 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1519 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1520 .with_attrs(
1521 ::serde_json::json!({"path": enabled_path, "error": format!("{}", e)})
1522 ),
1523 "failed to default-enable newly created channel; operator must toggle manually"
1524 );
1525 }
1526 }
1527 (format!("channels.{key}.{alias}"), created)
1528 }
1529 Section::Agents
1530 | Section::PeerGroups
1531 | Section::Cron
1532 | Section::McpBundles
1533 | Section::KnowledgeBundles
1534 | Section::SkillBundles
1535 | Section::RiskProfiles
1536 | Section::RuntimeProfiles => {
1537 let section_key = section_enum.as_str();
1543 let created = working.create_map_key(section_key, &key).map_err(|msg| {
1544 error_response(
1545 ConfigApiError::new(
1546 ConfigApiCode::PathNotFound,
1547 format!("could not select {section_key} alias `{key}`: {msg}"),
1548 )
1549 .with_path(section_key),
1550 )
1551 });
1552 let created = match created {
1553 Ok(c) => c,
1554 Err(resp) => return resp,
1555 };
1556 if created && matches!(section_enum, Section::Agents) {
1560 apply_first_run_agent_defaults(&mut working, &key);
1561 let workspace_dir = working.agent_workspace_dir(&key);
1562 if let Err(err) = tokio::fs::create_dir_all(&workspace_dir).await {
1563 return error_response(
1564 ConfigApiError::new(
1565 ConfigApiCode::ValidationFailed,
1566 format!(
1567 "created agent `{key}` but failed to scaffold workspace at {}: {err}",
1568 workspace_dir.display()
1569 ),
1570 )
1571 .with_path(section_key),
1572 );
1573 }
1574 if let Err(err) =
1575 zeroclaw_config::schema::ensure_bootstrap_files(&workspace_dir).await
1576 {
1577 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"agent": key, "workspace": workspace_dir.display().to_string(), "err": err.to_string()})), "agent workspace scaffolded but bootstrap files seed failed (continuing)");
1578 }
1579 }
1580 (format!("{section_key}.{key}"), created)
1581 }
1582 Section::Storage => {
1583 let created = working
1589 .create_map_key(&format!("storage.{key}"), &alias)
1590 .map_err(|msg| {
1591 error_response(
1592 ConfigApiError::new(
1593 ConfigApiCode::PathNotFound,
1594 format!("could not select storage `{key}` alias `{alias}`: {msg}"),
1595 )
1596 .with_path(format!("storage.{key}")),
1597 )
1598 });
1599 let created = match created {
1600 Ok(c) => c,
1601 Err(resp) => return resp,
1602 };
1603 mark_onboard_section_completed(&mut working, "storage");
1604 (format!("storage.{key}.{alias}"), created)
1605 }
1606 Section::Memory => {
1607 if let Err(e) = working.set_prop_persistent("memory.backend", &key) {
1611 return error_response(
1612 ConfigApiError::new(
1613 ConfigApiCode::ValidationFailed,
1614 format!("could not set memory.backend = `{key}`: {e}"),
1615 )
1616 .with_path("memory.backend"),
1617 );
1618 }
1619 mark_onboard_section_completed(&mut working, "memory");
1620 ("memory".to_string(), true)
1621 }
1622 Section::Tunnel => {
1623 if let Err(e) = working.set_prop_persistent("tunnel.tunnel-provider", &key) {
1624 return error_response(
1625 ConfigApiError::new(
1626 ConfigApiCode::ValidationFailed,
1627 format!("could not set tunnel.tunnel-provider = `{key}`: {e}"),
1628 )
1629 .with_path("tunnel.tunnel-provider"),
1630 );
1631 }
1632 let prefix = if key == "none" {
1633 "tunnel".to_string()
1634 } else {
1635 let p = format!("tunnel.{key}");
1636 working.init_defaults(Some(&p));
1637 p
1638 };
1639 (prefix, true)
1640 }
1641 Section::Hardware | Section::Mcp | Section::Skills => {
1642 return error_response(
1643 ConfigApiError::new(
1644 ConfigApiCode::PathNotFound,
1645 format!(
1646 "section `{}` is a direct-form section with no picker; \
1647 render fields via GET /api/config/list?prefix={}",
1648 section_enum, section_enum
1649 ),
1650 )
1651 .with_path(section_enum.as_str()),
1652 );
1653 }
1654 };
1655
1656 if created {
1657 working.mark_dirty(&fields_prefix);
1658 }
1659
1660 if let Err(e) = working.save_dirty().await {
1661 return error_response(ConfigApiError::new(
1662 ConfigApiCode::ReloadFailed,
1663 format!("save after select failed: {e}"),
1664 ));
1665 }
1666 *state.config.write() = working;
1667
1668 axum::Json(SelectItemResponse {
1669 fields_prefix,
1670 created,
1671 })
1672 .into_response()
1673}
1674
1675#[cfg(test)]
1676mod tests {
1677 use super::*;
1678
1679 #[test]
1687 fn build_agent_options_returns_every_configured_alias() {
1688 let mut cfg = zeroclaw_config::schema::Config::default();
1689 cfg.create_map_key("providers.models.anthropic", "default")
1690 .unwrap();
1691 cfg.create_map_key("risk-profiles", "alpha_risk").unwrap();
1692 cfg.create_map_key("runtime-profiles", "alpha_runtime")
1693 .unwrap();
1694 cfg.create_map_key("skill-bundles", "alpha_skills").unwrap();
1695 cfg.create_map_key("knowledge-bundles", "alpha_knowledge")
1696 .unwrap();
1697 cfg.create_map_key("mcp-bundles", "alpha_mcp").unwrap();
1698 cfg.create_map_key("agents", "alpha_agent").unwrap();
1699
1700 let resp = build_agent_options(&cfg);
1701
1702 assert_eq!(resp.model_providers, vec!["anthropic.default".to_string()]);
1703 assert_eq!(resp.risk_profiles, vec!["alpha_risk".to_string()]);
1704 assert_eq!(resp.runtime_profiles, vec!["alpha_runtime".to_string()]);
1705 assert_eq!(resp.skill_bundles, vec!["alpha_skills".to_string()]);
1706 assert_eq!(resp.knowledge_bundles, vec!["alpha_knowledge".to_string()],);
1707 assert_eq!(resp.mcp_bundles, vec!["alpha_mcp".to_string()]);
1708 assert_eq!(resp.agents, vec!["alpha_agent".to_string()]);
1709 }
1710
1711 #[test]
1712 fn typed_provider_catalog_keys_can_create_kebab_config_sections() {
1713 let mut cfg = zeroclaw_config::schema::Config::default();
1714 let cases = [
1715 (
1716 zeroclaw_config::sections::Section::ModelProviders,
1717 "providers.models",
1718 "atomic_chat",
1719 "providers.models.atomic-chat",
1720 ),
1721 (
1722 zeroclaw_config::sections::Section::ModelProviders,
1723 "providers.models",
1724 "gemini_cli",
1725 "providers.models.gemini-cli",
1726 ),
1727 (
1728 zeroclaw_config::sections::Section::TranscriptionProviders,
1729 "providers.transcription",
1730 "local_whisper",
1731 "providers.transcription.local-whisper",
1732 ),
1733 ];
1734
1735 for (section, family, key, expected_path) in cases {
1736 let path = format!("{family}.{}", typed_family_config_key(section, key));
1737 assert_eq!(path, expected_path);
1738 cfg.create_map_key(&path, "default")
1739 .unwrap_or_else(|e| panic!("{key} should map to `{expected_path}`: {e}"));
1740 }
1741
1742 assert!(
1743 cfg.providers.models.atomic_chat.contains_key("default"),
1744 "created Atomic Chat alias should land in the atomic_chat provider map",
1745 );
1746 assert!(
1747 cfg.providers.models.gemini_cli.contains_key("default"),
1748 "created Gemini CLI alias should land in the gemini_cli provider map",
1749 );
1750 assert!(
1751 cfg.providers
1752 .transcription
1753 .local_whisper
1754 .contains_key("default"),
1755 "created Local Whisper alias should land in the local_whisper provider map",
1756 );
1757 }
1758
1759 #[test]
1760 fn derive_onboard_status_requires_dispatchable_agent() {
1761 let mut cfg = zeroclaw_config::schema::Config::default();
1762 let resp = derive_onboard_status(&cfg);
1763 assert!(resp.needs_onboarding);
1764 assert_eq!(resp.reason, "fresh_install");
1765
1766 cfg.create_map_key("providers.models.anthropic", "default")
1767 .unwrap();
1768 let resp = derive_onboard_status(&cfg);
1769 assert!(
1770 resp.needs_onboarding,
1771 "provider configured without a bound agent must not flip needs_onboarding"
1772 );
1773 assert_eq!(resp.reason, "incomplete_agent");
1774 assert!(resp.has_partial_state);
1775
1776 cfg.create_map_key("risk-profiles", "default").unwrap();
1777 cfg.create_map_key("runtime-profiles", "default").unwrap();
1778 cfg.create_map_key("agents", "default").unwrap();
1779 let resp = derive_onboard_status(&cfg);
1780 assert!(
1781 resp.needs_onboarding,
1782 "agent without provider/profile bindings must still need onboarding"
1783 );
1784 assert_eq!(resp.reason, "incomplete_agent");
1785 assert!(
1786 resp.missing
1787 .iter()
1788 .any(|m| m == "Set a model provider for agent `default`.")
1789 );
1790
1791 let agent = cfg.agents.get_mut("default").unwrap();
1792 agent.model_provider = "anthropic.default".into();
1793 agent.risk_profile = "default".into();
1794 agent.runtime_profile = "default".into();
1795 let resp = derive_onboard_status(&cfg);
1796 assert!(
1797 resp.needs_onboarding,
1798 "provider alias without a selected model must still need onboarding"
1799 );
1800 assert!(
1801 resp.missing
1802 .iter()
1803 .any(|m| m == "Choose a model for model provider `anthropic.default`.")
1804 );
1805
1806 cfg.set_prop_persistent("providers.models.anthropic.default.model", "claude-sonnet")
1807 .unwrap();
1808 let resp = derive_onboard_status(&cfg);
1809 assert!(
1810 resp.needs_onboarding,
1811 "hosted provider alias without credential/auth must still need onboarding"
1812 );
1813 assert!(
1814 resp.missing
1815 .iter()
1816 .any(|m| m == "Set credential/auth for model provider `anthropic.default`.")
1817 );
1818
1819 cfg.set_prop_persistent("providers.models.anthropic.default.api-key", "sk-test")
1820 .unwrap();
1821 let resp = derive_onboard_status(&cfg);
1822 assert!(!resp.needs_onboarding);
1823 assert_eq!(resp.reason, "has_dispatchable_agent");
1824 assert!(!resp.has_partial_state || resp.missing.is_empty());
1825 }
1826
1827 #[test]
1828 fn derive_onboard_status_completed_sections_without_dispatchable_agent_stays_pending() {
1829 let mut cfg = zeroclaw_config::schema::Config::default();
1830 cfg.onboard_state
1831 .completed_sections
1832 .push("providers.models".into());
1833 let resp = derive_onboard_status(&cfg);
1834 assert!(
1835 resp.needs_onboarding,
1836 "completed_sections marker without a dispatchable agent must NOT flip the redirect"
1837 );
1838 assert_eq!(resp.reason, "incomplete_agent");
1839 }
1840
1841 #[test]
1842 fn derive_onboard_status_returns_structured_repair_items_for_half_configured_agent() {
1843 let mut cfg = zeroclaw_config::schema::Config::default();
1844 cfg.create_map_key("providers.models.atomic-chat", "local")
1845 .unwrap();
1846 cfg.create_map_key("risk-profiles", "default").unwrap();
1847 cfg.create_map_key("agents", "default").unwrap();
1848 let agent = cfg.agents.get_mut("default").unwrap();
1849 agent.enabled = true;
1850 agent.model_provider = "atomic_chat.local".into();
1851 agent.risk_profile = "default".into();
1852
1853 let resp = derive_onboard_status(&cfg);
1854
1855 assert!(resp.needs_onboarding);
1856 assert_eq!(resp.reason, "incomplete_agent");
1857 let provider_item = resp
1858 .repair_items
1859 .iter()
1860 .find(|item| item.code == "model_provider_model_missing")
1861 .expect("model repair item");
1862 assert_eq!(provider_item.section, "providers.models");
1863 assert_eq!(
1864 provider_item.focus.as_deref(),
1865 Some("providers.models.atomic-chat.local")
1866 );
1867 assert_eq!(
1868 provider_item.message,
1869 "Choose a model for model provider `atomic_chat.local`."
1870 );
1871 let runtime_item = resp
1872 .repair_items
1873 .iter()
1874 .find(|item| item.code == "agent_runtime_profile_missing")
1875 .expect("runtime repair item");
1876 assert_eq!(runtime_item.section, "agents");
1877 assert_eq!(runtime_item.focus.as_deref(), Some("agents.default"));
1878 assert!(
1879 resp.missing
1880 .iter()
1881 .any(|item| item == "Set a runtime profile for agent `default`.")
1882 );
1883 }
1884
1885 #[test]
1886 fn apply_first_run_agent_defaults_binds_existing_provider_and_profiles() {
1887 let mut cfg = zeroclaw_config::schema::Config::default();
1888 cfg.create_map_key("providers.models.anthropic", "work")
1889 .unwrap();
1890 cfg.create_map_key("risk-profiles", "default").unwrap();
1891 cfg.create_map_key("runtime-profiles", "deep_work").unwrap();
1892 cfg.create_map_key("agents", "default").unwrap();
1893
1894 apply_first_run_agent_defaults(&mut cfg, "default");
1895
1896 let agent = cfg.agents.get("default").unwrap();
1897 assert_eq!(agent.model_provider.as_str(), "anthropic.work");
1898 assert_eq!(agent.risk_profile, "default");
1899 assert_eq!(agent.runtime_profile, "deep_work");
1900 }
1901
1902 #[test]
1903 fn memory_section_ready_tracks_onboarding_progress_not_default_backend() {
1904 let cfg = zeroclaw_config::schema::Config::default();
1905 assert!(
1906 !section_ready(&cfg, "memory", false),
1907 "fresh onboarding should not show Memory checked merely because a default backend exists"
1908 );
1909 assert!(
1910 section_ready(&cfg, "memory", true),
1911 "Memory should show checked after the user has advanced through that section"
1912 );
1913 }
1914
1915 #[tokio::test]
1916 async fn catalog_models_uses_alias_local_uri() {
1917 let base_url =
1918 spawn_openai_compatible_models_server(r#"{"data":[{"id":"llama3.2:latest"}]}"#).await;
1919 let cfg = ollama_alias_config(&base_url);
1920
1921 let resp = catalog_models_for_config(&cfg, "ollama", Some("default")).await;
1922
1923 assert!(resp.local);
1924 assert!(resp.live);
1925 assert_eq!(resp.models, vec!["llama3.2:latest"]);
1926 }
1927
1928 #[tokio::test]
1929 async fn catalog_models_keeps_live_empty_alias_catalog() {
1930 let base_url = spawn_openai_compatible_models_server(r#"{"data":[]}"#).await;
1931 let cfg = ollama_alias_config(&base_url);
1932
1933 let resp = catalog_models_for_config(&cfg, "ollama", Some("default")).await;
1934
1935 assert!(resp.local);
1936 assert!(
1937 resp.live,
1938 "reachable local endpoint with no models is still live"
1939 );
1940 assert!(resp.models.is_empty());
1941 }
1942
1943 #[tokio::test]
1944 async fn catalog_models_marks_unreachable_local_alias_not_live() {
1945 let cfg = ollama_alias_config("http://127.0.0.1:1");
1946
1947 let resp = catalog_models_for_config(&cfg, "ollama", Some("default")).await;
1948
1949 assert!(resp.local);
1950 assert!(!resp.live);
1951 assert!(resp.models.is_empty());
1952 }
1953
1954 #[tokio::test]
1955 async fn catalog_models_marks_unreachable_hosted_alias_endpoint_not_live() {
1956 let mut cfg = zeroclaw_config::schema::Config::default();
1957 cfg.create_map_key("providers.models.moonshot", "default")
1958 .unwrap();
1959 cfg.set_prop_persistent("providers.models.moonshot.default.api-key", "sk-test")
1960 .unwrap();
1961 cfg.set_prop_persistent(
1962 "providers.models.moonshot.default.uri",
1963 "http://127.0.0.1:1",
1964 )
1965 .unwrap();
1966
1967 let resp = catalog_models_for_config(&cfg, "moonshot", Some("default")).await;
1968
1969 assert!(!resp.local);
1970 assert!(!resp.live);
1971 assert!(resp.models.is_empty());
1972 }
1973
1974 #[tokio::test]
1975 async fn catalog_models_missing_alias_does_not_probe_default_endpoint() {
1976 let base_url =
1977 spawn_openai_compatible_models_server(r#"{"data":[{"id":"llama3.2:latest"}]}"#).await;
1978 let cfg = ollama_alias_config(&base_url);
1979
1980 let resp = catalog_models_for_config(&cfg, "ollama", Some("missing")).await;
1981
1982 assert!(resp.local);
1983 assert!(!resp.live);
1984 assert!(resp.models.is_empty());
1985 }
1986
1987 fn ollama_alias_config(base_url: &str) -> zeroclaw_config::schema::Config {
1988 let mut cfg = zeroclaw_config::schema::Config::default();
1989 cfg.create_map_key("providers.models.ollama", "default")
1990 .unwrap();
1991 cfg.set_prop_persistent("providers.models.ollama.default.uri", base_url)
1992 .unwrap();
1993 cfg
1994 }
1995
1996 async fn spawn_openai_compatible_models_server(body: &'static str) -> String {
1997 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1998 let addr = listener.local_addr().unwrap();
1999 tokio::spawn(async move {
2000 loop {
2001 let Ok((mut stream, _)) = listener.accept().await else {
2002 break;
2003 };
2004 tokio::spawn(async move {
2005 use tokio::io::{AsyncReadExt, AsyncWriteExt};
2006
2007 let mut buf = [0_u8; 1024];
2008 let n = stream.read(&mut buf).await.unwrap_or(0);
2009 let request = String::from_utf8_lossy(&buf[..n]);
2010 let response = if request.starts_with("GET /v1/models ") {
2011 format!(
2012 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
2013 body.len(),
2014 body,
2015 )
2016 } else {
2017 let body = r#"{"error":"unexpected path"}"#;
2018 format!(
2019 "HTTP/1.1 404 Not Found\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
2020 body.len(),
2021 body,
2022 )
2023 };
2024 let _ = stream.write_all(response.as_bytes()).await;
2025 });
2026 }
2027 });
2028 format!("http://{addr}")
2029 }
2030
2031 fn empty_cfg() -> zeroclaw_config::schema::Config {
2032 zeroclaw_config::schema::Config::default()
2033 }
2034
2035 #[test]
2036 fn handle_sections_derives_every_top_level_field_from_schema() {
2037 let cfg = empty_cfg();
2041 let mut roots: std::collections::BTreeSet<String> = cfg
2042 .prop_fields()
2043 .iter()
2044 .filter_map(|f| f.name.split('.').next().map(str::to_string))
2045 .collect();
2046 for s in zeroclaw_config::schema::Config::map_key_sections() {
2050 if let Some(first) = s.path.split('.').next() {
2051 roots.insert(first.to_string());
2052 }
2053 }
2054 for hidden in HIDDEN_TOP_LEVEL {
2055 roots.remove(*hidden);
2056 }
2057 for required in ["providers", "channels", "memory", "hardware", "tunnel"] {
2059 assert!(
2060 roots.contains(required),
2061 "derived sections must include onboarding section `{required}`; got {roots:?}",
2062 );
2063 }
2064 for runtime in ["gateway", "observability", "scheduler", "security"] {
2066 assert!(
2067 roots.contains(runtime),
2068 "derived sections must include runtime section `{runtime}`; got {roots:?}",
2069 );
2070 }
2071 for hidden in HIDDEN_TOP_LEVEL {
2073 assert!(
2074 !roots.contains(*hidden),
2075 "hidden top-level `{hidden}` must not appear",
2076 );
2077 }
2078 for hidden in ["onboard_state", "onboard-state"] {
2079 assert!(
2080 !roots.contains(hidden),
2081 "onboarding bookkeeping root `{hidden}` must not appear",
2082 );
2083 }
2084 }
2085
2086 #[test]
2087 fn channels_select_initializes_subsection_so_set_prop_works() {
2088 let mut cfg = empty_cfg();
2095 assert!(cfg.channels.matrix.is_empty(), "fresh config: matrix unset");
2096
2097 cfg.create_map_key("channels.matrix", "mymatrixalias")
2098 .expect("create_map_key must succeed for channels.matrix");
2099 assert!(
2100 cfg.channels.matrix.contains_key("mymatrixalias"),
2101 "channels.matrix must have alias after create_map_key",
2102 );
2103
2104 cfg.set_prop(
2106 "channels.matrix.mymatrixalias.allowed-rooms",
2107 r#"["alice","bob"]"#,
2108 )
2109 .expect("set_prop on initialized matrix subsection must succeed");
2110 assert_eq!(
2111 cfg.channels
2112 .matrix
2113 .get("mymatrixalias")
2114 .unwrap()
2115 .allowed_rooms,
2116 vec!["alice".to_string(), "bob".to_string()],
2117 );
2118 }
2119
2120 #[test]
2121 fn providers_picker_sources_from_list_providers() {
2122 let cfg = empty_cfg();
2125 let items = providers_picker(&cfg);
2126 let names: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
2127 assert!(
2128 names.contains(&"anthropic"),
2129 "expected anthropic in picker, got: {names:?}"
2130 );
2131 assert!(names.contains(&"openai"), "expected openai in picker");
2132 assert!(
2133 names.contains(&"openrouter"),
2134 "expected openrouter in picker"
2135 );
2136
2137 let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
2139 assert_eq!(anthropic.label, "Anthropic");
2140
2141 let local = items.iter().find(|i| i.description.is_some());
2143 assert!(
2144 local.is_some(),
2145 "at least one model_provider should be marked local"
2146 );
2147
2148 assert!(
2150 items.iter().all(|i| i.badge.is_none()),
2151 "fresh config shouldn't mark any model_provider as present"
2152 );
2153 }
2154
2155 #[test]
2156 fn providers_picker_marks_alias_readiness() {
2157 let mut cfg = empty_cfg();
2162 cfg.create_map_key("providers.models.anthropic", "default")
2163 .expect("create_map_key");
2164 let items = providers_picker(&cfg);
2165 let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
2166 assert_eq!(
2167 anthropic.badge.as_deref(),
2168 Some("needs setup"),
2169 "anthropic should need setup after adding an empty alias"
2170 );
2171
2172 cfg.set_prop(
2173 "providers.models.anthropic.default.model",
2174 "claude-sonnet-4-5",
2175 )
2176 .expect("set model");
2177 cfg.set_prop("providers.models.anthropic.default.api-key", "sk-test")
2178 .expect("set api key");
2179 let items = providers_picker(&cfg);
2180 let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
2181 assert_eq!(
2182 anthropic.badge.as_deref(),
2183 Some("configured"),
2184 "anthropic should be marked configured once required chat fields are present"
2185 );
2186 }
2187
2188 #[test]
2189 fn memory_picker_sources_from_selectable_backends() {
2190 let cfg = empty_cfg();
2191 let items = memory_picker(&cfg);
2192 let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
2194 assert!(keys.contains(&"sqlite"));
2195 assert!(keys.contains(&"none"));
2196 let active = items.iter().find(|i| i.badge.as_deref() == Some("active"));
2198 assert!(
2199 active.is_none(),
2200 "fresh onboarding should not mark a memory backend active before the user confirms the step"
2201 );
2202 }
2203
2204 #[test]
2205 fn channels_picker_walks_schema_via_init_defaults() {
2206 let cfg = empty_cfg();
2211 let items = schema_walk_picker(&cfg, "channels");
2212 let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
2213 assert!(!keys.is_empty(), "channel picker must not be empty");
2214 for expected in ["telegram", "slack", "discord"] {
2217 assert!(
2218 keys.contains(&expected),
2219 "expected `{expected}` in channels picker, got: {keys:?}"
2220 );
2221 }
2222 assert!(
2224 items.iter().all(|i| i.badge.is_none()),
2225 "fresh config shouldn't mark any channel as configured"
2226 );
2227 }
2228
2229 #[test]
2230 fn channels_picker_marks_configured_after_create_map_key() {
2231 let mut cfg = empty_cfg();
2232 cfg.create_map_key("channels.matrix", "mymatrixalias")
2233 .expect("create_map_key must succeed for channels.matrix");
2234 let items = schema_walk_picker(&cfg, "channels");
2235 let matrix = items.iter().find(|i| i.key == "matrix").unwrap();
2236 assert_eq!(
2237 matrix.badge.as_deref(),
2238 Some("configured"),
2239 "matrix should be marked configured after create_map_key"
2240 );
2241 }
2242
2243 #[test]
2244 fn tunnel_picker_includes_synthetic_none() {
2245 let cfg = empty_cfg();
2246 let items = schema_walk_picker_with_none(&cfg, "tunnel", "tunnel.tunnel-provider");
2247 assert_eq!(
2248 items[0].key, "none",
2249 "`none` must be the first entry in the tunnel picker"
2250 );
2251 assert_eq!(items[0].badge.as_deref(), Some("active"));
2253 }
2254
2255 #[test]
2261 fn one_tier_alias_map_picker_is_empty_for_unconfigured_section() {
2262 let cfg = empty_cfg();
2263 for section in [
2264 "peer-groups",
2265 "cron",
2266 "mcp-bundles",
2267 "knowledge-bundles",
2268 "skill-bundles",
2269 "risk-profiles",
2270 "runtime-profiles",
2271 ] {
2272 let items = one_tier_alias_map_picker(&cfg, section);
2273 assert!(
2274 items.is_empty(),
2275 "`{section}` picker must be empty on a fresh config, got: {:?}",
2276 items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(),
2277 );
2278 }
2279 }
2280
2281 #[test]
2286 fn one_tier_alias_map_picker_surfaces_created_aliases() {
2287 let cases: &[(&str, &str)] = &[
2288 ("peer-groups", "team_chat"),
2289 ("cron", "daily_brief"),
2290 ("mcp-bundles", "core_tools"),
2291 ("knowledge-bundles", "house_docs"),
2292 ("skill-bundles", "ops_skills"),
2293 ("risk-profiles", "tight"),
2294 ("runtime-profiles", "fast_model"),
2295 ];
2296 for (section, alias) in cases {
2297 let mut cfg = empty_cfg();
2298 cfg.create_map_key(section, alias)
2299 .unwrap_or_else(|e| panic!("create_map_key({section}, {alias}) failed: {e}"));
2300 let items = one_tier_alias_map_picker(&cfg, section);
2301 assert!(
2302 items.iter().any(|i| i.key == *alias),
2303 "`{section}` picker should surface `{alias}` after create_map_key; got: {:?}",
2304 items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(),
2305 );
2306 let entry = items.iter().find(|i| i.key == *alias).unwrap();
2307 assert_eq!(
2308 entry.badge.as_deref(),
2309 Some("configured"),
2310 "`{section}.{alias}` should be badged `configured`",
2311 );
2312 }
2313 }
2314
2315 #[test]
2324 fn picker_dispatch_covers_every_section_variant() {
2325 use zeroclaw_config::sections::Section;
2326 let cfg = empty_cfg();
2327 let all: &[Section] = &[
2331 Section::ModelProviders,
2332 Section::TtsProviders,
2333 Section::TranscriptionProviders,
2334 Section::Channels,
2335 Section::Memory,
2336 Section::Hardware,
2337 Section::Tunnel,
2338 Section::Agents,
2339 Section::PeerGroups,
2340 Section::Storage,
2341 Section::Cron,
2342 Section::Mcp,
2343 Section::McpBundles,
2344 Section::KnowledgeBundles,
2345 Section::SkillBundles,
2346 Section::RiskProfiles,
2347 Section::RuntimeProfiles,
2348 ];
2349 let direct_form = [Section::Hardware, Section::Mcp];
2350 for section in all {
2351 match picker_items_for(*section, &cfg) {
2352 PickerDispatch::Items(_items) => {
2353 assert!(
2354 !direct_form.contains(section),
2355 "{section:?} is marked DirectForm but dispatched to Items",
2356 );
2357 }
2358 PickerDispatch::DirectForm => {
2359 assert!(
2360 direct_form.contains(section),
2361 "{section:?} returned DirectForm but is not in the DirectForm set; \
2362 either give it a picker arm or add it to the DirectForm list",
2363 );
2364 }
2365 }
2366 }
2367 }
2368
2369 #[test]
2375 fn storage_picker_lists_all_kinds_and_marks_created() {
2376 let cfg = empty_cfg();
2377 let items = storage_picker(&cfg);
2378 let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
2379 for expected in ["sqlite", "postgres", "qdrant", "markdown", "lucid"] {
2380 assert!(
2381 keys.contains(&expected),
2382 "storage picker must list `{expected}`, got: {keys:?}",
2383 );
2384 }
2385 assert!(
2387 items.iter().all(|i| i.badge.is_none()),
2388 "fresh config: no storage kind should be marked configured",
2389 );
2390
2391 let mut cfg2 = empty_cfg();
2393 cfg2.create_map_key("storage.sqlite", "primary")
2394 .expect("create_map_key(storage.sqlite, primary) must succeed");
2395 let items = storage_picker(&cfg2);
2396 let sqlite = items.iter().find(|i| i.key == "sqlite").unwrap();
2397 assert_eq!(
2398 sqlite.badge.as_deref(),
2399 Some("created"),
2400 "storage.sqlite should be marked created after adding an alias",
2401 );
2402 assert!(
2403 sqlite.description.is_some(),
2404 "storage picker should explain each backend tradeoff",
2405 );
2406 }
2407}