Skip to main content

zeroclaw_gateway/
api_onboard.rs

1//! Onboard catalog endpoint — exposes the model_provider + model catalog the CLI
2//! wizard already uses, so the dashboard's "+ Add model_provider" affordance and
3//! model-picker dropdown share the same source of truth as the CLI.
4//!
5//! No catalog data is hand-maintained at this layer. `list_model_providers()` lives
6//! in `zeroclaw-providers` and is the canonical list; `list_models()` per
7//! model_provider fetches from models.dev (cached) or the model_provider's own /models
8//! endpoint. Same code paths as the CLI wizard.
9//!
10//!
11
12use 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    /// Canonical model_provider name as used in `[model_providers.<name>]`.
28    pub name: String,
29    /// Human-readable display name.
30    pub display_name: String,
31    /// Whether the model model_provider is fully local (no API key required).
32    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
41/// `GET /api/onboard/catalog` — list every model provider the CLI wizard knows
42/// about. The dashboard shows these in the "+ Add model provider" picker so
43/// CLI / web stay in sync.
44pub 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    /// ModelProvider name (canonical, from CatalogModelProvider.name).
66    /// `provider` alias matches the query-string name the web dashboard uses.
67    #[serde(alias = "provider")]
68    pub model_provider: String,
69    /// Optional configured alias under `providers.models.<provider>.<alias>`.
70    /// When present, the catalog endpoint validates that alias's own URI/auth
71    /// instead of only checking the provider family's default endpoint.
72    #[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    /// Whether this provider family is local according to the canonical
82    /// provider catalog.
83    pub local: bool,
84    /// `true` when the catalog was fetched live; `false` if the cache was
85    /// served (or if this model_provider has no remote catalog and the empty list
86    /// is the genuine answer).
87    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
215/// `GET /api/onboard/catalog/models?model_provider=<name>` — fetch the model list
216/// for one model_provider. Same code path the CLI wizard uses
217/// (`zeroclaw_providers::create_model_provider(...).list_models()`), which goes
218/// through the models.dev cached catalog for OpenAI / Anthropic / Gemini,
219/// the live `/v1/models` endpoint for OpenRouter, etc.
220///
221/// Lazy: the dashboard hits this only when the user picks a model_provider, so
222/// initial catalog load stays fast. Fetch failures return an empty list
223/// with `live: false` so the form falls back to a free-text input.
224pub 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// ── Section + picker (mirrors the TUI flow) ──────────────────────────
244
245#[derive(Debug, Serialize)]
246#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
247pub struct SectionInfo {
248    /// Stable section key — `model_providers`, `channels`, `memory`,
249    /// `hardware`, `tunnel`. Matches `Section::as_path_prefix` in
250    /// zeroclaw-runtime so CLI / web stay aligned.
251    pub key: String,
252    /// Human-readable section name for headers / breadcrumbs.
253    pub label: String,
254    /// Help text the wizard shows under the section title.
255    pub help: String,
256    /// `true` when this section requires picking an item before the form
257    /// renders (Providers / Channels / Memory / Tunnel). `false` for sections
258    /// that have a single direct form (Hardware).
259    pub has_picker: bool,
260    /// Whether the user has marked the section completed in
261    /// `onboard_state.completed_sections`.
262    pub completed: bool,
263    /// Whether the section currently has enough usable config for the
264    /// first-run path. This is stricter than `completed`: visiting a section
265    /// can mark it completed, but the sidebar checkmark should not imply a
266    /// provider or agent is runnable when required fields are still missing.
267    pub ready: bool,
268    /// Display group for the dashboard sidebar (`Foundation`, `Agent`,
269    /// `Tools`, etc.). Curated server-side until v3 / #5947 lands a schema
270    /// attribute that encodes the grouping declaratively.
271    pub group: String,
272    /// `true` when this section is part of `/onboard`'s canonical
273    /// section list (`zeroclaw_config::sections::ONBOARDING_SECTIONS`).
274    /// Since the wizard/explorer split was retired, every known section
275    /// returns `true`; the field is preserved for API stability so the
276    /// frontend's `.filter((s) => s.is_onboarding)` stays a no-op rather
277    /// than failing to compile.
278    pub is_onboarding: bool,
279    /// Editor shape (direct form / one-tier alias map / typed-family map /
280    /// backend picker). Server-emitted from
281    /// `zeroclaw_config::sections::Section::shape()`; both the
282    /// dashboard explorer and the onboard wizard dispatch their renderer
283    /// off this flag so identical sections render identically.
284    /// `None` for sections that aren't part of the canonical wizard.
285    #[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    /// Stable machine-readable reason. The web UI uses this for targeted
299    /// onboarding repair controls without parsing localized copy.
300    pub code: &'static str,
301    /// Human-readable repair instruction for the current non-localized UI.
302    pub message: String,
303    /// Onboarding section that contains the repair surface.
304    pub section: &'static str,
305    /// Optional config prefix the UI can open directly.
306    #[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    /// `true` when no agent is dispatchable yet. The dashboard uses this
314    /// signal to redirect first-load visits from `/` to `/onboard`.
315    pub needs_onboarding: bool,
316    /// Short machine-readable reason for the value of `needs_onboarding`,
317    /// for logs / debugging. Stable: `fresh_install` / `incomplete_agent`
318    /// / `has_dispatchable_agent`.
319    pub reason: &'static str,
320    /// `true` when the operator has started entering setup state even if no
321    /// agent can reply yet. The dashboard uses this to say "Continue
322    /// onboarding" instead of pretending the flow is fresh.
323    pub has_partial_state: bool,
324    /// Human-readable readiness failures. When onboarding cannot finish, the
325    /// UI shows these directly so the operator knows exactly what is missing.
326    pub missing: Vec<String>,
327    /// Structured repair checklist for half-configured installs. Mirrors
328    /// `missing` but keeps stable codes and targets for UI routing.
329    pub repair_items: Vec<OnboardRepairItem>,
330}
331
332/// Pure derivation of the onboard-status response from a config snapshot.
333/// `needs_onboarding` is `false` iff at least one enabled `[agents.<alias>]`
334/// block has a resolved model provider with a selected model plus resolved
335/// risk/runtime profile refs. A provider without a bound, runnable agent is
336/// not a completion signal: chat dispatch still bounces with a setup error in
337/// that state.
338#[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
560/// `GET /api/onboard/status` — boolean signal for the dashboard's
561/// fresh-install redirect. The daemon writes a default `config.toml` on
562/// first init, so file existence isn't a useful "is the user new?" check.
563/// Onboarding is complete iff at least one agent has its
564/// `model_provider`, `risk_profile`, and `runtime_profile` bound.
565pub 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/// All alias-reference choices an agent form needs, in one round-trip.
574/// Channels and model model_providers are returned in dotted form
575/// (`telegram.default`, `anthropic.work`); the bundle/profile/namespace
576/// lists are bare HashMap keys.
577#[derive(Debug, Serialize)]
578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
579pub struct AgentOptionsResponse {
580    pub channels: Vec<String>,
581    /// Distinct channel types with at least one configured alias —
582    /// `["discord", "telegram"]`. Source for peer-group channel picker.
583    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
593/// Build the `AgentOptionsResponse` from a config snapshot. Pure function
594/// so tests can drive the same code path the handler runs without spinning
595/// up an `AppState`.
596///
597/// `get_map_keys` expects **kebab-case** paths (the macro at
598/// `crates/zeroclaw-macros/src/lib.rs:366` builds lookup arms with
599/// `snake_to_kebab(field_name)`). Passing snake_case for any
600/// underscore-bearing field silently returns `None` → empty `Vec` →
601/// dashboard renders "No X configured yet" even though X is configured.
602pub 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
642/// `GET /api/onboard/agent-options` — every alias-reference list the
643/// agent form needs, derived from the live config. Mirrors the lists the
644/// TUI computes locally for its alias pickers.
645pub 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
653/// `GET /api/onboard/sections` — list every top-level config section.
654///
655/// Schema-driven: walks `Config::prop_fields()` and collects unique first
656/// segments, then asks `Config::map_key_sections()` for which ones have
657/// pickers. The 4 onboarding sections (`model_providers`, `channels`, `memory`,
658/// `tunnel`) keep their existing per-section dispatch in
659/// `handle_section_picker`; everything else (`gateway`, `observability`,
660/// `scheduler`, ...) renders as a direct form. Adding a new top-level
661/// field to `Config` makes it appear here automatically.
662pub 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    // First segment of every reachable prop path. BTreeSet for stable
675    // alphabetical order and dedup.
676    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    // System / housekeeping fields the user never edits via the dashboard.
683    for hidden in HIDDEN_TOP_LEVEL {
684        roots.remove(*hidden);
685    }
686
687    // A section gets a picker only when its OWN root carries a map (path
688    // == key) or its immediate child is a typed-family map (path == key
689    // + "." + one segment). Deeper nested maps belong to a subsection's
690    // own editor and must not promote their top-level section to a
691    // picker — `cost.rates.providers.models.<type>` is the rate-sheet's
692    // concern, not a reason to give `[cost]` an Add affordance.
693    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    // Ensure map-keyed sections surface as sidebar entries even when their
707    // HashMap is empty (prop_fields() only yields paths for populated
708    // entries). First segments only — the prefix-dedup pass below drops
709    // bare parent segments when a multi-segment child is present.
710    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    // Synthetic onboarding sections — keys that aren't fields on Config
719    // but are part of the wizard flow (personality lives as markdown
720    // files, not TOML). Inject so the canonical-order sort places them
721    // correctly and frontends don't need to know which ones to splice.
722    for s in zeroclaw_config::sections::ONBOARDING_SECTIONS {
723        roots.insert(s.as_str().to_string());
724    }
725
726    // Drop bare parent-segment entries when a dotted child is present
727    // — `providers` is phantom once `providers.models` etc. are listed.
728    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    // Hard-ban the rate-sheet subtree from the sidebar. `[cost.rates.*]` is
735    // edited from inside the `[cost]` section's tabs (and from each
736    // provider-type page's Costs tab); it has no standalone picker, no
737    // direct form at any intermediate depth, and surfacing a path like
738    // `cost.rates.providers.tts` as its own sidebar entry only yields a
739    // dead-end "no picker" page.
740    roots.retain(|k| !k.starts_with("cost.rates"));
741
742    // Sort: onboarding-wizard sections first in their canonical order
743    // (single source of truth in `zeroclaw_config::sections`), then
744    // everything else alphabetically. This is what makes /onboard's wizard
745    // order and /config's foundation grouping derive from one Rust const
746    // — frontends consume the response order directly.
747    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            // Picker eligibility = anything `handle_section_picker`
764            // dispatches non-trivially. Wizard sections that opt out
765            // (workspace/hardware/personality) are direct-form. Map-keyed
766            // sections outside the wizard (multi-agent peer groups, etc.)
767            // get the generic schema-walk picker.
768            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
814/// Top-level fields that exist on `Config` but are never user-editable
815/// from the dashboard (schema bookkeeping, resolved at runtime).
816const 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
826/// Humanize a section key for display (`google_workspace` → `Google workspace`).
827/// Keeps things simple and predictable; specific wording overrides go in
828/// the section-help table or per-section labels if/when we add them.
829fn 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
843/// Display group for a section. Hand-curated until v3 / #5947 lands a
844/// schema attribute that encodes grouping declaratively. Unknown keys
845/// fall into `Other` so new schema additions still surface — they just
846/// land in the catch-all bucket until someone curates them.
847///
848/// Group order in the dashboard sidebar is governed by the frontend (see
849/// `Config.tsx`), not this list.
850fn 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 loop, scheduling, and orchestration.
857        "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        // Multi-agent / delegation.
870        "delegate" => "Multi-agent",
871        // Tool integrations.
872        "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        // External services / vendor integrations. ACP is included because
877        // it is always client-paired — you cannot use it without a client.
878        "acp" | "claude_code" | "claude_code_runner" | "codex_cli" | "composio" | "gemini_cli"
879        | "google_workspace" | "jira" | "linkedin" | "notion" | "opencode_cli" => "Integrations",
880        // Networking / multi-node infrastructure.
881        "gateway" | "node_transport" | "nodes" | "proxy" => "Network",
882        // Storage, identity, secrets.
883        "identity" | "secrets" | "storage" => "Storage",
884        // Operations / monitoring / safety / cost.
885        "backup" | "cloud_ops" | "conversational_ai" | "cost" | "data_retention"
886        | "observability" | "peripherals" | "security" | "security_ops" | "trust" => "Operations",
887        _ => "Other",
888    }
889}
890
891/// Help text for a section. Delegates to `zeroclaw_config::sections::section_help`
892/// so gateway, CLI, and TUI all read from one source — wizard variants
893/// pull from `Section::help`, everything else from the matching
894/// `#[nested]` field's `///` docstring on the `Config` struct.
895fn 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    /// Stable identifier — what the frontend POSTs back to select this item.
909    pub key: String,
910    /// Human-readable label for display (catalog display_name, channel name,
911    /// memory backend label, etc.).
912    pub label: String,
913    /// Optional secondary line under the label (e.g. memory backend's
914    /// extended description, "(local)" for local-only model_providers).
915    #[serde(skip_serializing_if = "Option::is_none")]
916    pub description: Option<String>,
917    /// Optional badge — `"set"` / `"needs setup"` / `"created"` /
918    /// `"configured"` / `"active"` depending on section semantics. The
919    /// frontend uses this to mark rows distinct without overstating readiness.
920    #[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    /// Help text for the picker (re-served from the section info so the
930    /// frontend doesn't need to round-trip).
931    pub help: String,
932}
933
934/// `GET /api/onboard/sections/<section>` — picker items for that section.
935///
936/// Per-section dispatch:
937/// * `providers` → `zeroclaw_providers::list_model_providers()` (CLI's catalog).
938/// * `memory` → `zeroclaw_memory::selectable_memory_backends()`.
939/// * `channels` / `tunnel` → schema-walk: clone config, `init_defaults` the
940///   section, then strip the section prefix from `prop_fields()` and dedupe
941///   by first segment. Same trick the TUI uses; new channels appear
942///   automatically when a `#[nested] Option<...>` field is added.
943/// * Anything else returns 404 (hardware has no picker).
944pub 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(&section) 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
992/// Result of picker dispatch for a [`Section`]. `Items` carries the
993/// list rendered into the dashboard / CLI picker UI; `DirectForm`
994/// signals a section without a picker step (the caller falls through
995/// to `/api/config/list?prefix=<section>` for direct field rendering).
996///
997/// Splitting this out from `handle_section_picker` keeps the per-Section
998/// dispatch a pure function — testable without an `AppState` mock and
999/// exhaustively coverable by iterating every variant.
1000enum PickerDispatch {
1001    Items(Vec<PickerItem>),
1002    DirectForm,
1003}
1004
1005/// Per-section picker dispatch. Exhaustive over [`Section`] so adding a
1006/// variant fails to compile until it gets a routing arm. The DRY
1007/// version of what the dashboard's per-section view boils down to.
1008fn 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        // TTS / transcription share the typed-family two-tier shape. Each
1016        // family enumerates its picker via `schema_walk_picker(<family>)`
1017        // — the same machinery channels uses, so no per-section catalog
1018        // table to drift.
1019        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        // Storage is two-tier (`storage.<kind>.<alias>`) — same shape
1031        // and walker as channels and the typed-provider families.
1032        Section::Storage => PickerDispatch::Items(storage_picker(cfg)),
1033        // OneTierAliasMap explorer sections: pick a key from the live
1034        // HashMap. Generic walker covers every section whose schema is
1035        // `<section>.<alias>` (operator-named keys, no closed kind set).
1036        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
1192/// Generic schema-walk picker for sections like `channels` whose subsections
1193/// are `#[nested] HashMap<String, T>` fields. Discovery: use `map_key_sections()`
1194/// to enumerate all statically-known sub-sections under `<section>.` — this
1195/// works for HashMap-based channels without needing init_defaults to insert
1196/// entries (HashMap fields start empty and init_defaults leaves them empty).
1197fn schema_walk_picker(cfg: &zeroclaw_config::schema::Config, section: &str) -> Vec<PickerItem> {
1198    let prefix_with_dot = format!("{section}.");
1199
1200    // Configured: any alias present on this type (has at least one entry in its HashMap).
1201    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    // All known channel/section types from schema metadata — statically known,
1209    // no HashMap entries needed.
1210    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            // Channel configs no longer carry an `enabled` field; a channel is
1224            // active when an enabled agent references it. Badge = "configured" when
1225            // at least one alias exists, absent otherwise.
1226            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
1241/// Generic picker for `OneTierAliasMap` sections — walks the live
1242/// `prop_fields()` for the section prefix and returns one PickerItem
1243/// per operator-defined alias. The closed-kind enumeration that
1244/// [`schema_walk_picker`] does via `Config::map_key_sections()` doesn't
1245/// apply here: aliases under `peer_groups`, `cron`, `risk_profiles`,
1246/// etc. are operator-named, with no statically-known catalog. Every
1247/// existing alias is reported `configured`; the dashboard's `+ Add`
1248/// affordance handles new-key creation through
1249/// [`handle_select_item`].
1250fn 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
1276/// Agents picker: walks `cfg.agents` and returns each alias with an activity badge.
1277/// `active` = agent exists and `enabled = true`; `configured` = exists but disabled.
1278fn 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
1346/// `tunnel`-flavored picker: same as `schema_walk_picker` plus a synthetic
1347/// `none` entry at the top, marked active when the current `tunnel.tunnel_provider`
1348/// matches. Mirrors the TUI's tunnel section.
1349fn 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    // Re-mark the active one in the schema-walk results.
1367    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    /// The dotted prefix the frontend should use for `GET /api/config/list?prefix=...`
1380    /// to render the form for the selected item. E.g. picking `anthropic`
1381    /// under Providers returns `model_providers.anthropic`.
1382    pub fields_prefix: String,
1383    /// True if this select created a new entry (vs. resolved to an existing one).
1384    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/// `POST /api/onboard/sections/<section>/items/<key>` — instantiate the
1395/// selected item in the live config (idempotent) and return the dotted
1396/// prefix the frontend should fetch fields under.
1397///
1398/// Per-section dispatch:
1399/// * `providers` → POST equivalent of `/api/config/map-key?path=providers.models&key=<key>`,
1400///   then return `model_providers.<key>`.
1401/// * `channels` → init_defaults under `channels.<key>`, return `channels.<key>`.
1402/// * `memory` → set_prop `memory.backend = <key>`, return `memory`.
1403/// * `tunnel` → set_prop `tunnel.tunnel_provider = <key>` (and init_defaults the
1404///   subsection if `<key>` is not "none"), return `tunnel.<key>` (or `tunnel`
1405///   for the `none` case).
1406///
1407/// The optional JSON body `{"alias": "<name>"}` names the entry being created,
1408/// e.g. `"work"` for `model_providers.anthropic.work`. Omit to use `"default"`.
1409#[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        // Provider catalog/runtime identifiers use snake_case
1423        // (`atomic_chat`, `local_whisper`), while Configurable field paths
1424        // are kebab-case (`atomic-chat`, `local-whisper`). Keep references
1425        // snake_case; normalize only the config-map path used to create and
1426        // edit the alias block.
1427        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(&section) 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            // Two-tier typed-family path: outer bucket is the family
1465            // (`model_providers.<type>` etc.), inner key is the alias the
1466            // operator named. `create_map_key` is idempotent so re-selecting
1467            // an existing type/alias is a no-op for the bucket and just
1468            // returns the form prefix for the alias.
1469            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            // Per-family typed configs derive their own default endpoint
1487            // URI via family traits at runtime construction time.
1488            (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            // The per-channel-type struct's `enabled` field defaults to
1507            // `false` (pre-v0.8.0 paste-safety rationale: don't fire a
1508            // listener on a half-pasted block). For wizard-driven creation
1509            // the operator has just consciously added the alias, so flip
1510            // the new entry's `enabled` to true. Re-selecting an existing
1511            // alias is a no-op (created=false), so user-edited values are
1512            // never trampled.
1513            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            // OneTierAliasMap: the URL path key IS the alias. One
1538            // `create_map_key("<section>", &key)` call works for every
1539            // operator-named HashMap section; create_map_key is
1540            // idempotent, so selecting an existing alias just returns
1541            // the form prefix without modifying anything.
1542            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            // Agents need a per-alias workspace dir on disk so the
1557            // PersonalityEditor and the runtime have somewhere to read
1558            // and write IDENTITY.md / SOUL.md / USER.md / etc.
1559            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            // Two-tier typed-family (`storage.<kind>.<alias>`) — same
1584            // shape and selection flow as model_providers / tts_providers /
1585            // transcription_providers. Outer bucket is the storage kind
1586            // (sqlite, postgres, qdrant, markdown, lucid); inner key is
1587            // the operator-named alias.
1588            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            // Set memory.backend to the picked key. Fields_prefix points at
1608            // `memory` so the form renders the whole memory section
1609            // (the active backend's specific fields show up there).
1610            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    /// Regression guard: every alias-bearing map the handler exposes must
1680    /// be reachable from `Config::get_map_keys` using the kebab-case path
1681    /// `build_agent_options` passes. Snake_case silently returns `None` →
1682    /// empty Vec → dashboard renders "No X configured yet" when X exists.
1683    /// This test drives the same code the gateway runs and would have
1684    /// caught the original bug. Adding a new alias-bearing field requires
1685    /// adding it here too.
1686    #[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        // Regression: the section list must be schema-driven, not the old
2038        // hardcoded 6. Adding a new top-level field to `Config` should make
2039        // it appear here automatically.
2040        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        // Mirror handle_sections: map-keyed sections surface even when
2047        // their HashMap is empty (prop_fields only emits paths for
2048        // populated entries).
2049        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        // The 5 onboarding sections must still be in the derived set.
2058        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        // Plus a sample of the runtime sections that used to be invisible.
2065        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        // System / housekeeping fields must NOT surface.
2072        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        // Regression for the channels init/set flow: after
2089        // handle_section_select for channels/matrix, the in-memory config
2090        // must have channels.matrix.<alias> so a subsequent set_prop on
2091        // channels.matrix.* succeeds rather than bailing "Unknown property".
2092        // Uses create_map_key directly (the synchronous core of the select
2093        // endpoint) to keep the test free of HTTP machinery.
2094        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        // The form would issue a PATCH whose set_prop call hits this path.
2105        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        // Single source of truth: zeroclaw_providers::list_model_providers().
2123        // Anthropic / OpenAI / OpenRouter must surface in the picker.
2124        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        // Display name is human-readable, not the canonical key.
2138        let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
2139        assert_eq!(anthropic.label, "Anthropic");
2140
2141        // Local-only model_providers carry a description hint.
2142        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        // Empty config has no model_provider aliases — no badges yet.
2149        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        // Typed-family layout: each canonical family is a map-keyed
2158        // sub-section at `model_providers.<family>` whose entries are
2159        // operator-named aliases. Creating the alias alone is not enough
2160        // for chat dispatch; it still needs model + credential/auth.
2161        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        // Mirrors zeroclaw_memory::selectable_memory_backends() exactly.
2193        let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
2194        assert!(keys.contains(&"sqlite"));
2195        assert!(keys.contains(&"none"));
2196        // Fresh onboarding should not imply the user selected the default.
2197        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        // Pure schema discovery — same trick the TUI uses. Whatever channels
2207        // the build has compiled in (matrix / discord / slack / etc.) appears
2208        // in the picker without any hand-maintained list. Test asserts a
2209        // representative sample compiled into the default `ci-all` build.
2210        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        // Channels that are unconditionally compiled (no feature gate)
2215        // should always appear:
2216        for expected in ["telegram", "slack", "discord"] {
2217            assert!(
2218                keys.contains(&expected),
2219                "expected `{expected}` in channels picker, got: {keys:?}"
2220            );
2221        }
2222        // Fresh config — nothing configured yet.
2223        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        // `none` is the active default for a fresh config.
2252        assert_eq!(items[0].badge.as_deref(), Some("active"));
2253    }
2254
2255    /// Empty OneTierAliasMap section yields zero picker items. No
2256    /// closed-kind catalog applies for these sections — only operator-defined
2257    /// aliases populate the picker. Section wire keys are kebab-case
2258    /// because the Configurable derive runs each field name through
2259    /// `snake_to_kebab` when registering map-key paths.
2260    #[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    /// After `create_map_key("<kebab-section>", "<alias>")`, the picker
2282    /// surfaces the alias as a `configured` entry. Same shape applies
2283    /// to every OneTierAliasMap section — the picker is generic over
2284    /// the prefix.
2285    #[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    /// Exhaustive picker dispatch: every [`Section`] variant must
2316    /// resolve through `picker_items_for` without panic. DirectForm
2317    /// sections (Workspace, Hardware, Mcp) return the
2318    /// `PickerDispatch::DirectForm` sentinel; every other section
2319    /// returns at least zero items. Loops over the wizard order plus
2320    /// every explorer-only variant — adding a new Section variant
2321    /// fails to compile until it gets a routing arm in
2322    /// `picker_items_for`.
2323    #[test]
2324    fn picker_dispatch_covers_every_section_variant() {
2325        use zeroclaw_config::sections::Section;
2326        let cfg = empty_cfg();
2327        // The full Section surface = wizard steps + explorer-only.
2328        // Spelling them out here pins both groups, so adding a row to
2329        // the `sections!` macro forces an update here too.
2330        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    /// Storage is `[storage.<kind>.<alias>]` — two-tier typed-family
2370    /// shape, served by the storage picker. The picker
2371    /// surfaces the 5 storage kinds (sqlite, postgres, qdrant,
2372    /// markdown, lucid) regardless of which aliases exist, and badges
2373    /// the kind `created` once any alias under it is created.
2374    #[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        // Fresh config — no kind should be badged.
2386        assert!(
2387            items.iter().all(|i| i.badge.is_none()),
2388            "fresh config: no storage kind should be marked configured",
2389        );
2390
2391        // Create a sqlite instance; the sqlite row should flip to configured.
2392        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}