Skip to main content

zeroclaw_gateway/
api_sections.rs

1//! Curated config-section endpoints. Used by the `/config` page in the
2//! web dashboard to navigate the schema by curated section rather than
3//! raw prop paths. OpenAPI is authoritative for the exact route set.
4
5use axum::{
6    extract::{Query, State},
7    http::HeaderMap,
8    response::{IntoResponse, Response},
9};
10use serde::{Deserialize, Serialize};
11use zeroclaw_config::api_error::{ConfigApiCode, ConfigApiError};
12use zeroclaw_runtime::rpc::types::{
13    CatalogModelProvider, CatalogModelsResult, CatalogResponse, ConfigSectionEntry,
14    ConfigSectionsResult, ConfigStatusResult, PickerItem, PickerResponse, SelectItemResponse,
15};
16
17use super::AppState;
18use super::api::require_auth;
19
20/// `GET /api/config/catalog` — list every model provider the CLI wizard knows
21/// about. The dashboard shows these in the "+ Add model provider" picker so
22/// CLI / web stay in sync.
23pub async fn handle_catalog(State(state): State<AppState>, headers: HeaderMap) -> Response {
24    if let Err(e) = require_auth(&state, &headers) {
25        return e.into_response();
26    }
27    let _ = state;
28
29    let model_providers: Vec<CatalogModelProvider> = zeroclaw_providers::list_model_providers()
30        .into_iter()
31        .map(|p| CatalogModelProvider {
32            name: p.name.to_string(),
33            display_name: p.display_name.to_string(),
34            local: p.local,
35        })
36        .collect();
37
38    axum::Json(CatalogResponse { model_providers }).into_response()
39}
40
41#[derive(Debug, Deserialize)]
42#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
43pub struct ModelsQuery {
44    /// ModelProvider name (canonical, from CatalogModelProvider.name).
45    /// `provider` alias matches the query-string name the web dashboard uses.
46    #[serde(alias = "provider")]
47    pub model_provider: String,
48}
49
50/// `GET /api/config/catalog/models?model_provider=<name>` — fetch the model list
51/// for one model_provider. Same code path the CLI wizard uses
52/// (`zeroclaw_providers::create_model_provider(...).list_models()`), which goes
53/// through the models.dev cached catalog for OpenAI / Anthropic / Gemini,
54/// the live `/v1/models` endpoint for OpenRouter, etc.
55///
56/// Lazy: the dashboard hits this only when the user picks a model_provider, so
57/// initial catalog load stays fast. Fetch failures return an empty list
58/// with `live: false` so the form falls back to a free-text input.
59pub async fn handle_catalog_models(
60    State(state): State<AppState>,
61    headers: HeaderMap,
62    Query(q): Query<ModelsQuery>,
63) -> Response {
64    if let Err(e) = require_auth(&state, &headers) {
65        return e.into_response();
66    }
67    let _ = state;
68    let local = zeroclaw_runtime::quickstart::model_provider_is_local(&q.model_provider);
69    let (models, pricing, live) =
70        zeroclaw_runtime::quickstart::model_catalog(&q.model_provider).await;
71    axum::Json(CatalogModelsResult {
72        model_provider: q.model_provider,
73        models,
74        pricing,
75        local,
76        live,
77    })
78    .into_response()
79}
80
81fn error_response(err: ConfigApiError) -> Response {
82    let status = axum::http::StatusCode::from_u16(err.code.http_status())
83        .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR);
84    (status, axum::Json(err)).into_response()
85}
86
87// ── Section + picker (mirrors the TUI flow) ──────────────────────────
88
89/// Pure derivation of the section status response from a config snapshot.
90/// `needs_quickstart` is `false` iff at least one enabled `[agents.<alias>]`
91/// block has a resolved model provider with a selected model plus resolved
92/// risk/runtime profile refs. A provider without a bound, runnable agent is
93/// not a completion signal: chat dispatch still bounces with a setup error in
94/// that state.
95#[must_use]
96pub fn derive_section_status(cfg: &zeroclaw_config::schema::Config) -> ConfigStatusResult {
97    let missing = quickstart_missing_requirements(cfg);
98    let ready = missing.is_empty();
99    let has_partial_state = !cfg.onboard_state.completed_sections.is_empty()
100        || cfg.providers.models.iter_entries().next().is_some()
101        || !cfg.risk_profiles.is_empty()
102        || !cfg.runtime_profiles.is_empty()
103        || !cfg.agents.is_empty();
104    let reason = if ready {
105        "has_dispatchable_agent"
106    } else if has_partial_state {
107        "incomplete_agent"
108    } else {
109        "fresh_install"
110    };
111    ConfigStatusResult {
112        needs_quickstart: !ready,
113        reason: reason.to_string(),
114        has_partial_state,
115        missing,
116    }
117}
118
119fn quickstart_missing_requirements(cfg: &zeroclaw_config::schema::Config) -> Vec<String> {
120    let mut missing = Vec::new();
121    if cfg.providers.models.iter_entries().next().is_none() {
122        missing.push("Add a model provider.".to_string());
123    }
124    if cfg.agents.is_empty() {
125        missing.push("Create an agent.".to_string());
126        return missing;
127    }
128
129    let mut agent_aliases: Vec<&String> = cfg.agents.keys().collect();
130    agent_aliases.sort();
131    let mut has_dispatchable_agent = false;
132    for alias in agent_aliases {
133        let agent_missing = quickstart_agent_missing_requirements(cfg, alias, &cfg.agents[alias]);
134        if agent_missing.is_empty() {
135            has_dispatchable_agent = true;
136            break;
137        }
138        missing.extend(agent_missing);
139    }
140    if has_dispatchable_agent {
141        missing.clear();
142    }
143    missing
144}
145
146fn quickstart_agent_missing_requirements(
147    cfg: &zeroclaw_config::schema::Config,
148    alias: &str,
149    agent: &zeroclaw_config::schema::AliasedAgentConfig,
150) -> Vec<String> {
151    let mut missing = Vec::new();
152    if !agent.enabled {
153        missing.push(format!("Enable agent `{alias}`."));
154    }
155
156    let model_ref = agent.model_provider.trim();
157    if model_ref.is_empty() {
158        missing.push(format!("Set a model provider for agent `{alias}`."));
159    } else if let Some((family, _, provider)) = cfg.resolved_model_provider_for_agent(alias) {
160        let has_model = provider
161            .model
162            .as_deref()
163            .map(str::trim)
164            .is_some_and(|m| !m.is_empty());
165        if !has_model {
166            missing.push(format!("Choose a model for model provider `{model_ref}`."));
167        } else if !model_provider_alias_usable(
168            provider,
169            zeroclaw_runtime::quickstart::model_provider_is_local(family),
170        ) {
171            missing.push(format!(
172                "Set credential/auth for model provider `{model_ref}`."
173            ));
174        }
175    } else {
176        missing.push(format!(
177            "Fix agent `{alias}` model provider `{model_ref}`; it does not resolve to a configured provider."
178        ));
179    }
180
181    let risk_ref = agent.risk_profile.trim();
182    if risk_ref.is_empty() {
183        missing.push(format!("Set a risk profile for agent `{alias}`."));
184    } else if !cfg.risk_profiles.contains_key(risk_ref) {
185        missing.push(format!(
186            "Fix agent `{alias}` risk profile `{risk_ref}`; it does not resolve to a configured profile."
187        ));
188    }
189
190    let runtime_ref = agent.runtime_profile.trim();
191    if runtime_ref.is_empty() {
192        missing.push(format!("Set a runtime profile for agent `{alias}`."));
193    } else if !cfg.runtime_profiles.contains_key(runtime_ref) {
194        missing.push(format!(
195            "Fix agent `{alias}` runtime profile `{runtime_ref}`; it does not resolve to a configured profile."
196        ));
197    }
198
199    missing
200}
201
202/// `GET /api/config/status` — boolean signal for the dashboard's
203/// fresh-install redirect. The daemon writes a default `config.toml` on
204/// first init, so file existence isn't a useful "is the user new?" check.
205/// Section status: ready iff at least one agent has its
206/// `model_provider`, `risk_profile`, and `runtime_profile` bound.
207pub async fn handle_section_status(State(state): State<AppState>, headers: HeaderMap) -> Response {
208    if let Err(e) = require_auth(&state, &headers) {
209        return e.into_response();
210    }
211    let cfg = state.config.read().clone();
212    axum::Json(derive_section_status(&cfg)).into_response()
213}
214
215/// All alias-reference choices an agent form needs, in one round-trip.
216/// Channels and model model_providers are returned in dotted form
217/// (`telegram.default`, `anthropic.work`); the bundle/profile/namespace
218/// lists are bare HashMap keys.
219#[derive(Debug, Serialize)]
220#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
221pub struct AgentOptionsResponse {
222    pub channels: Vec<String>,
223    /// Distinct channel types with at least one configured alias —
224    /// `["discord", "telegram"]`. Source for peer-group channel picker.
225    pub channel_types: Vec<String>,
226    pub model_providers: Vec<String>,
227    pub risk_profiles: Vec<String>,
228    pub runtime_profiles: Vec<String>,
229    pub skill_bundles: Vec<String>,
230    pub knowledge_bundles: Vec<String>,
231    pub mcp_bundles: Vec<String>,
232    pub agents: Vec<String>,
233}
234
235/// Build the `AgentOptionsResponse` from a config snapshot. Pure function
236/// so tests can drive the same code path the handler runs without spinning
237/// up an `AppState`.
238///
239/// `get_map_keys` expects **kebab-case** paths (the macro at
240/// `crates/zeroclaw-macros/src/lib.rs:366` builds lookup arms with
241/// `snake_to_kebab(field_name)`). Passing snake_case for any
242/// underscore-bearing field silently returns `None` → empty `Vec` →
243/// dashboard renders "No X configured yet" even though X is configured.
244pub fn build_agent_options(cfg: &zeroclaw_config::schema::Config) -> AgentOptionsResponse {
245    fn dotted_aliases(cfg: &zeroclaw_config::schema::Config, prefix: &str) -> Vec<String> {
246        let mut out: Vec<String> = Vec::new();
247        for f in cfg.prop_fields() {
248            if let Some(rest) = f.name.strip_prefix(&format!("{prefix}.")) {
249                let mut parts = rest.splitn(3, '.');
250                if let (Some(ty), Some(alias), Some(_)) = (parts.next(), parts.next(), parts.next())
251                {
252                    let dotted = format!("{ty}.{alias}");
253                    if !out.contains(&dotted) {
254                        out.push(dotted);
255                    }
256                }
257            }
258        }
259        out.sort();
260        out
261    }
262
263    let channels = dotted_aliases(cfg, "channels");
264    let mut channel_types: Vec<String> = channels
265        .iter()
266        .filter_map(|d| d.split_once('.').map(|(t, _)| t.to_string()))
267        .collect();
268    channel_types.sort();
269    channel_types.dedup();
270
271    AgentOptionsResponse {
272        channels,
273        channel_types,
274        model_providers: dotted_aliases(cfg, "providers.models"),
275        risk_profiles: cfg.get_map_keys("risk_profiles").unwrap_or_default(),
276        runtime_profiles: cfg.get_map_keys("runtime_profiles").unwrap_or_default(),
277        skill_bundles: cfg.get_map_keys("skill_bundles").unwrap_or_default(),
278        knowledge_bundles: cfg.get_map_keys("knowledge_bundles").unwrap_or_default(),
279        mcp_bundles: cfg.get_map_keys("mcp_bundles").unwrap_or_default(),
280        agents: cfg.get_map_keys("agents").unwrap_or_default(),
281    }
282}
283
284/// `GET /api/config/agent-options` — every alias-reference list the
285/// agent form needs, derived from the live config. Mirrors the lists the
286/// TUI computes locally for its alias pickers.
287pub async fn handle_agent_options(State(state): State<AppState>, headers: HeaderMap) -> Response {
288    if let Err(e) = require_auth(&state, &headers) {
289        return e.into_response();
290    }
291    let cfg = state.config.read().clone();
292    axum::Json(build_agent_options(&cfg)).into_response()
293}
294
295/// `GET /api/config/sections` — list every top-level config section.
296///
297/// Schema-driven: walks `Config::prop_fields()` and collects unique first
298/// segments, then asks `Config::map_key_sections()` for which ones have
299/// pickers. The 4 quickstart sections (`model_providers`, `channels`, `memory`,
300/// `tunnel`) keep their existing per-section dispatch in
301/// `handle_section_picker`; everything else (`gateway`, `observability`,
302/// `scheduler`, ...) renders as a direct form. Adding a new top-level
303/// field to `Config` makes it appear here automatically.
304pub async fn handle_sections(State(state): State<AppState>, headers: HeaderMap) -> Response {
305    if let Err(e) = require_auth(&state, &headers) {
306        return e.into_response();
307    }
308    let cfg = state.config.read().clone();
309    let completed: std::collections::HashSet<String> = cfg
310        .onboard_state
311        .completed_sections
312        .iter()
313        .cloned()
314        .collect();
315
316    // First segment of every reachable prop path. BTreeSet for stable
317    // alphabetical order and dedup.
318    let mut roots: std::collections::BTreeSet<String> = cfg
319        .prop_fields()
320        .iter()
321        .filter_map(|f| f.name.split('.').next().map(str::to_string))
322        .collect();
323
324    // System / housekeeping fields the user never edits via the dashboard.
325    for hidden in HIDDEN_TOP_LEVEL {
326        roots.remove(*hidden);
327    }
328
329    // A section gets a picker only when its OWN root carries a map (path
330    // == key) or its immediate child is a typed-family map (path == key
331    // + "." + one segment). Deeper nested maps belong to a subsection's
332    // own editor and must not promote their top-level section to a
333    // picker — `cost.rates.providers.models.<type>` is the rate-sheet's
334    // concern, not a reason to give `[cost]` an Add affordance.
335    let all_map_paths: Vec<&'static str> = zeroclaw_config::schema::Config::map_key_sections()
336        .iter()
337        .map(|s| s.path)
338        .collect();
339    let section_has_picker_for_key = |key: &str| -> bool {
340        let key_dot = format!("{key}.");
341        all_map_paths.iter().any(|p| {
342            *p == key
343                || p.strip_prefix(&key_dot)
344                    .is_some_and(|rest| !rest.contains('.'))
345        })
346    };
347
348    // Ensure map-keyed sections surface as sidebar entries even when their
349    // HashMap is empty (prop_fields() only yields paths for populated
350    // entries). First segments only — the prefix-dedup pass below drops
351    // bare parent segments when a multi-segment child is present.
352    let map_keyed_roots: std::collections::HashSet<&'static str> = all_map_paths
353        .iter()
354        .filter_map(|p| p.split('.').next())
355        .collect();
356    for &prefix in &map_keyed_roots {
357        roots.insert(prefix.to_string());
358    }
359
360    // Synthetic curated sections — keys that aren't fields on Config
361    // but are part of the wizard flow (personality lives as markdown
362    // files, not TOML). Inject so the canonical-order sort places them
363    // correctly and frontends don't need to know which ones to splice.
364    for s in zeroclaw_config::sections::QUICKSTART_SECTIONS {
365        roots.insert(s.as_str().to_string());
366    }
367
368    // Drop bare parent-segment entries when a dotted child is present
369    // — `providers` is phantom once `providers.models` etc. are listed.
370    let prefixes_with_children: std::collections::HashSet<String> = roots
371        .iter()
372        .filter_map(|k| k.split_once('.').map(|(parent, _)| parent.to_string()))
373        .collect();
374    roots.retain(|k| k.contains('.') || !prefixes_with_children.contains(k));
375
376    // Hard-ban the rate-sheet subtree from the sidebar. `[cost.rates.*]` is
377    // edited from inside the `[cost]` section's tabs (and from each
378    // provider-type page's Costs tab); it has no standalone picker, no
379    // direct form at any intermediate depth, and surfacing a path like
380    // `cost.rates.providers.tts` as its own sidebar entry only yields a
381    // dead-end "no picker" page.
382    roots.retain(|k| !k.starts_with("cost.rates"));
383
384    // Sort: curated sections first in their canonical order
385    // (single source of truth in `zeroclaw_config::sections`), then
386    // everything else alphabetically. This is what makes /quickstart's wizard
387    // order and /config's foundation grouping derive from one Rust const
388    // — frontends consume the response order directly.
389    let mut ordered: Vec<String> = roots.into_iter().collect();
390    ordered.sort_by(|a, b| {
391        match (
392            zeroclaw_config::sections::section_index_for_key(a),
393            zeroclaw_config::sections::section_index_for_key(b),
394        ) {
395            (Some(ai), Some(bi)) => ai.cmp(&bi),
396            (Some(_), None) => std::cmp::Ordering::Less,
397            (None, Some(_)) => std::cmp::Ordering::Greater,
398            (None, None) => a.cmp(b),
399        }
400    });
401
402    let sections: Vec<ConfigSectionEntry> = ordered
403        .into_iter()
404        .map(|key| {
405            // Picker eligibility = anything `handle_section_picker`
406            // dispatches non-trivially. Wizard sections that opt out
407            // (workspace/hardware/personality) are direct-form. Map-keyed
408            // sections outside the wizard (multi-agent peer groups, etc.)
409            // get the generic schema-walk picker.
410            let wizard = zeroclaw_config::sections::Section::from_key(&key);
411            let has_picker = match wizard {
412                Some(w) => !matches!(
413                    w,
414                    zeroclaw_config::sections::Section::Hardware
415                        | zeroclaw_config::sections::Section::Mcp
416                        | zeroclaw_config::sections::Section::Skills
417                ),
418                None => section_has_picker_for_key(&key),
419            };
420            ConfigSectionEntry {
421                completed: completed.contains(&key),
422                ready: section_ready(&cfg, &key, completed.contains(&key)),
423                label: zeroclaw_config::sections::humanize_section_key(&key),
424                help: section_help(&key).to_string(),
425                has_picker,
426                group: section_group(&key).to_string(),
427                is_quickstart: wizard.is_some(),
428                shape: wizard.map(zeroclaw_config::sections::Section::shape),
429                key,
430            }
431        })
432        .collect();
433
434    axum::Json(ConfigSectionsResult { sections }).into_response()
435}
436
437fn section_ready(cfg: &zeroclaw_config::schema::Config, key: &str, completed_marker: bool) -> bool {
438    use zeroclaw_config::sections::Section;
439    match Section::from_key(key) {
440        Some(Section::ModelProviders) => any_usable_model_provider(cfg),
441        Some(Section::RiskProfiles) => !cfg.risk_profiles.is_empty(),
442        Some(Section::RuntimeProfiles) => !cfg.runtime_profiles.is_empty(),
443        Some(Section::Storage) => cfg
444            .prop_fields()
445            .iter()
446            .any(|field| field.name.starts_with("storage.")),
447        Some(Section::Memory) => completed_marker,
448        Some(Section::Agents) => cfg.agents.iter().any(|(alias, agent)| {
449            quickstart_agent_missing_requirements(cfg, alias, agent).is_empty()
450        }),
451        _ => completed_marker,
452    }
453}
454
455/// Top-level fields that exist on `Config` but are never user-editable
456/// from the dashboard (schema bookkeeping, resolved at runtime).
457const HIDDEN_TOP_LEVEL: &[&str] = &[
458    "schema_version",
459    "onboard_state",
460    "onboard-state",
461    "config_path",
462    "workspace_dir",
463    "env_overridden_paths",
464    "pre_override_snapshots",
465];
466
467/// Display group for a section. Hand-curated until v3 / #5947 lands a
468/// schema attribute that encodes grouping declaratively. Unknown keys
469/// fall into `Other` so new schema additions still surface — they just
470/// land in the catch-all bucket until someone curates them.
471///
472/// Group order in the dashboard sidebar is governed by the frontend (see
473/// `Config.tsx`), not this list.
474fn section_group(key: &str) -> &'static str {
475    match key {
476        "providers.models" | "channels" | "memory" | "hardware" | "tunnel" | "agents"
477        | "skills" | "skill_bundles" | "risk_profiles" | "runtime_profiles" | "peer_groups" => {
478            "Foundation"
479        }
480        // Agent loop, scheduling, and orchestration.
481        "agent"
482        | "cron"
483        | "heartbeat"
484        | "hooks"
485        | "pacing"
486        | "pipeline"
487        | "query_classification"
488        | "reliability"
489        | "runtime"
490        | "scheduler"
491        | "sop"
492        | "verifiable_intent" => "Agent",
493        // Multi-agent / delegation.
494        "delegate" => "Multi-agent",
495        // Tool integrations.
496        "browser" | "browser_delegate" | "http_request" | "image_gen" | "knowledge"
497        | "link_enricher" | "mcp" | "media_pipeline" | "multimodal" | "plugins"
498        | "project_intel" | "shell_tool" | "text_browser" | "transcription" | "tts"
499        | "web_fetch" | "web_search" => "Tools",
500        // External services / vendor integrations. ACP is included because
501        // it is always client-paired — you cannot use it without a client.
502        "acp" | "claude_code" | "claude_code_runner" | "codex_cli" | "composio" | "gemini_cli"
503        | "google_workspace" | "jira" | "linkedin" | "notion" | "opencode_cli" => "Integrations",
504        // Networking / multi-node infrastructure.
505        "gateway" | "node_transport" | "nodes" | "proxy" => "Network",
506        // Storage, identity, secrets.
507        "identity" | "secrets" | "storage" => "Storage",
508        // Operations / monitoring / safety / cost.
509        "backup" | "cloud_ops" | "conversational_ai" | "cost" | "data_retention"
510        | "observability" | "peripherals" | "security" | "security_ops" | "trust" => "Operations",
511        _ => "Other",
512    }
513}
514
515/// Help text for a section. Delegates to `zeroclaw_config::sections::section_help`
516/// so gateway, CLI, and TUI all read from one source — wizard variants
517/// pull from `Section::help`, everything else from the matching
518/// `#[nested]` field's `///` docstring on the `Config` struct.
519fn section_help(key: &str) -> &'static str {
520    zeroclaw_config::sections::section_help(key)
521}
522
523#[derive(Debug, Deserialize)]
524#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
525pub struct SectionPath {
526    pub section: String,
527}
528
529/// `GET /api/config/sections/<section>` — picker items for that section.
530///
531/// Per-section dispatch:
532/// * `providers` → `zeroclaw_providers::list_model_providers()` (CLI's catalog).
533/// * `memory` → `zeroclaw_memory::selectable_memory_backends()`.
534/// * `channels` / `tunnel` → schema-walk: clone config, `init_defaults` the
535///   section, then strip the section prefix from `prop_fields()` and dedupe
536///   by first segment. Same trick the TUI uses; new channels appear
537///   automatically when a `#[nested] Option<...>` field is added.
538/// * Anything else returns 404 (hardware has no picker).
539pub async fn handle_section_picker(
540    State(state): State<AppState>,
541    headers: HeaderMap,
542    axum::extract::Path(SectionPath { section }): axum::extract::Path<SectionPath>,
543) -> Response {
544    if let Err(e) = require_auth(&state, &headers) {
545        return e.into_response();
546    }
547    let cfg = state.config.read().clone();
548
549    use zeroclaw_config::sections::Section;
550    let Some(section_enum) = Section::from_key(&section) else {
551        return error_response(
552            ConfigApiError::new(
553                ConfigApiCode::PathNotFound,
554                format!(
555                    "section `{section}` has no picker; render its fields \
556                     via GET /api/config/list?prefix={section}"
557                ),
558            )
559            .with_path(section.as_str()),
560        );
561    };
562    let help = section_help(section_enum.as_str()).to_string();
563    let items = match picker_items_for(section_enum, &cfg) {
564        PickerDispatch::Items(items) => items,
565        PickerDispatch::DirectForm => {
566            return error_response(
567                ConfigApiError::new(
568                    ConfigApiCode::PathNotFound,
569                    format!(
570                        "section `{section_enum}` is a direct-form section with no picker; \
571                         render fields via GET /api/config/list?prefix={section_enum}"
572                    ),
573                )
574                .with_path(section_enum.as_str()),
575            );
576        }
577    };
578
579    axum::Json(PickerResponse {
580        section,
581        items,
582        help,
583    })
584    .into_response()
585}
586
587/// Result of picker dispatch for a [`Section`]. `Items` carries the
588/// list rendered into the dashboard / CLI picker UI; `DirectForm`
589/// signals a section without a picker step (the caller falls through
590/// to `/api/config/list?prefix=<section>` for direct field rendering).
591///
592/// Splitting this out from `handle_section_picker` keeps the per-Section
593/// dispatch a pure function — testable without an `AppState` mock and
594/// exhaustively coverable by iterating every variant.
595enum PickerDispatch {
596    Items(Vec<PickerItem>),
597    DirectForm,
598}
599
600/// Per-section picker dispatch. Exhaustive over [`Section`] so adding a
601/// variant fails to compile until it gets a routing arm. The DRY
602/// version of what the dashboard's per-section view boils down to.
603fn picker_items_for(
604    section: zeroclaw_config::sections::Section,
605    cfg: &zeroclaw_config::schema::Config,
606) -> PickerDispatch {
607    use zeroclaw_config::sections::Section;
608    match section {
609        Section::ModelProviders => PickerDispatch::Items(providers_picker(cfg)),
610        // TTS / transcription share the typed-family two-tier shape. Each
611        // family enumerates its picker via `schema_walk_picker(<family>)`
612        // — the same machinery channels uses, so no per-section catalog
613        // table to drift.
614        Section::TtsProviders | Section::TranscriptionProviders => {
615            PickerDispatch::Items(schema_walk_picker(cfg, section.as_str()))
616        }
617        Section::Memory => PickerDispatch::Items(memory_picker(cfg)),
618        Section::Channels => PickerDispatch::Items(schema_walk_picker(cfg, "channels")),
619        Section::Tunnel => PickerDispatch::Items(schema_walk_picker_with_none(
620            cfg,
621            "tunnel",
622            "tunnel.tunnel-provider",
623        )),
624        Section::Agents => PickerDispatch::Items(agents_picker(cfg)),
625        // Storage is two-tier (`storage.<kind>.<alias>`) — same shape
626        // and walker as channels and the typed-provider families.
627        Section::Storage => PickerDispatch::Items(storage_picker(cfg)),
628        // OneTierAliasMap explorer sections: pick a key from the live
629        // HashMap. Generic walker covers every section whose schema is
630        // `<section>.<alias>` (operator-named keys, no closed kind set).
631        Section::PeerGroups
632        | Section::Cron
633        | Section::McpBundles
634        | Section::KnowledgeBundles
635        | Section::SkillBundles
636        | Section::RiskProfiles
637        | Section::RuntimeProfiles => {
638            PickerDispatch::Items(one_tier_alias_map_picker(cfg, section.as_str()))
639        }
640        Section::Hardware | Section::Mcp | Section::Skills | Section::QuickstartState => {
641            PickerDispatch::DirectForm
642        }
643    }
644}
645
646fn providers_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
647    zeroclaw_providers::list_model_providers()
648        .into_iter()
649        .map(|p| PickerItem {
650            key: p.name.to_string(),
651            label: p.display_name.to_string(),
652            description: if p.local {
653                Some("Local — no API key required".to_string())
654            } else {
655                None
656            },
657            badge: provider_type_badge(cfg, p.name, p.local),
658        })
659        .collect()
660}
661
662fn any_usable_model_provider(cfg: &zeroclaw_config::schema::Config) -> bool {
663    cfg.providers
664        .models
665        .iter_entries()
666        .any(|(family, _, base)| {
667            model_provider_alias_usable(
668                base,
669                zeroclaw_runtime::quickstart::model_provider_is_local(family),
670            )
671        })
672}
673
674fn provider_type_badge(
675    cfg: &zeroclaw_config::schema::Config,
676    family: &str,
677    local: bool,
678) -> Option<String> {
679    let mut has_alias = false;
680    let mut has_usable_alias = false;
681    for (ty, _, base) in cfg.providers.models.iter_entries() {
682        if ty != family {
683            continue;
684        }
685        has_alias = true;
686        if model_provider_alias_usable(base, local) {
687            has_usable_alias = true;
688        }
689    }
690    if has_usable_alias {
691        Some("configured".to_string())
692    } else if has_alias {
693        Some("needs setup".to_string())
694    } else {
695        None
696    }
697}
698
699fn model_provider_alias_usable(
700    base: &zeroclaw_config::schema::ModelProviderConfig,
701    local: bool,
702) -> bool {
703    let has_model = base
704        .model
705        .as_deref()
706        .map(str::trim)
707        .is_some_and(|model| !model.is_empty());
708    if !has_model {
709        return false;
710    }
711    base.api_key
712        .as_deref()
713        .map(str::trim)
714        .is_some_and(|key| !key.is_empty())
715        || base.requires_openai_auth
716        || local
717}
718
719fn storage_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
720    let mut items = schema_walk_picker(cfg, "storage");
721    for item in &mut items {
722        item.description = storage_description(&item.key).map(str::to_string);
723        if item.badge.as_deref() == Some("configured") {
724            item.badge = Some("created".to_string());
725        }
726    }
727    items.sort_by_key(|item| storage_rank(&item.key));
728    items
729}
730
731fn storage_rank(key: &str) -> usize {
732    match key {
733        "sqlite" => 0,
734        "postgres" => 1,
735        "qdrant" => 2,
736        "markdown" => 3,
737        "lucid" => 4,
738        _ => 99,
739    }
740}
741
742fn storage_description(key: &str) -> Option<&'static str> {
743    match key {
744        "sqlite" => Some(
745            "Safe default for single-node installs: file-based, zero-config, no external service.",
746        ),
747        "postgres" => {
748            Some("Shared or multi-instance deployments that need durable server-backed storage.")
749        }
750        "qdrant" => {
751            Some("Vector database backend for semantic search when you already run Qdrant.")
752        }
753        "markdown" => {
754            Some("Human-readable files with simple local storage and no database service.")
755        }
756        "lucid" => {
757            Some("Bridge to local lucid-memory CLI while keeping SQLite-style local operation.")
758        }
759        _ => None,
760    }
761}
762
763fn memory_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
764    let current = cfg.memory.backend.clone();
765    let memory_completed = cfg
766        .onboard_state
767        .completed_sections
768        .iter()
769        .any(|section| section == "memory");
770    zeroclaw_memory::selectable_memory_backends()
771        .iter()
772        .map(|b| PickerItem {
773            key: b.key.to_string(),
774            label: b.label.to_string(),
775            description: None,
776            badge: if b.key == current && memory_completed {
777                Some("active".to_string())
778            } else {
779                None
780            },
781        })
782        .collect()
783}
784
785/// Generic schema-walk picker for sections like `channels` whose subsections
786/// are `#[nested] HashMap<String, T>` fields. Discovery: use `map_key_sections()`
787/// to enumerate all statically-known sub-sections under `<section>.` — this
788/// works for HashMap-based channels without needing init_defaults to insert
789/// entries (HashMap fields start empty and init_defaults leaves them empty).
790fn schema_walk_picker(cfg: &zeroclaw_config::schema::Config, section: &str) -> Vec<PickerItem> {
791    let prefix_with_dot = format!("{section}.");
792
793    // Configured: any alias present on this type (has at least one entry in its HashMap).
794    let configured: std::collections::BTreeSet<String> = cfg
795        .prop_fields()
796        .iter()
797        .filter_map(|f| f.name.strip_prefix(&prefix_with_dot))
798        .filter_map(|suffix| suffix.split_once('.').map(|(head, _)| head.to_string()))
799        .collect();
800
801    // All known channel/section types from schema metadata — statically known,
802    // no HashMap entries needed.
803    let all: std::collections::BTreeSet<String> =
804        zeroclaw_config::schema::Config::map_key_sections()
805            .into_iter()
806            .filter_map(|s| {
807                s.path
808                    .strip_prefix(&prefix_with_dot)
809                    .filter(|rest| !rest.contains('.'))
810                    .map(String::from)
811            })
812            .collect();
813
814    all.into_iter()
815        .map(|name| {
816            // Channel configs no longer carry an `enabled` field; a channel is
817            // active when an enabled agent references it. Badge = "configured" when
818            // at least one alias exists, absent otherwise.
819            let badge = if configured.contains(&name) {
820                Some("configured".to_string())
821            } else {
822                None
823            };
824            PickerItem {
825                key: name.clone(),
826                label: name.clone(),
827                description: None,
828                badge,
829            }
830        })
831        .collect()
832}
833
834/// Generic picker for `OneTierAliasMap` sections — walks the live
835/// `prop_fields()` for the section prefix and returns one PickerItem
836/// per operator-defined alias. The closed-kind enumeration that
837/// [`schema_walk_picker`] does via `Config::map_key_sections()` doesn't
838/// apply here: aliases under `peer_groups`, `cron`, `risk_profiles`,
839/// etc. are operator-named, with no statically-known catalog. Every
840/// existing alias is reported `configured`; the dashboard's `+ Add`
841/// affordance handles new-key creation through
842/// [`handle_select_item`].
843fn one_tier_alias_map_picker(
844    cfg: &zeroclaw_config::schema::Config,
845    section: &str,
846) -> Vec<PickerItem> {
847    let prefix_with_dot = format!("{section}.");
848    let mut keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
849    for field in cfg.prop_fields() {
850        let Some(suffix) = field.name.strip_prefix(&prefix_with_dot) else {
851            continue;
852        };
853        let head = suffix.split_once('.').map_or(suffix, |(h, _)| h);
854        if head.is_empty() {
855            continue;
856        }
857        keys.insert(head.to_string());
858    }
859    keys.into_iter()
860        .map(|key| PickerItem {
861            key: key.clone(),
862            label: key,
863            description: None,
864            badge: Some("configured".to_string()),
865        })
866        .collect()
867}
868
869/// Agents picker: walks `cfg.agents` and returns each alias with an activity badge.
870/// `active` = agent exists and `enabled = true`; `configured` = exists but disabled.
871fn agents_picker(cfg: &zeroclaw_config::schema::Config) -> Vec<PickerItem> {
872    let mut items: Vec<PickerItem> = cfg
873        .agents
874        .iter()
875        .map(|(alias, agent)| PickerItem {
876            key: alias.clone(),
877            label: alias.clone(),
878            description: None,
879            badge: if agent.enabled {
880                Some("active".to_string())
881            } else {
882                Some("configured".to_string())
883            },
884        })
885        .collect();
886    items.sort_by(|a, b| a.key.cmp(&b.key));
887    items
888}
889
890fn apply_first_run_agent_defaults(cfg: &mut zeroclaw_config::schema::Config, alias: &str) {
891    let model_provider = cfg
892        .providers
893        .models
894        .iter_entries()
895        .next()
896        .map(|(ty, alias, _)| format!("{ty}.{alias}"));
897    let risk_profile = first_alias(cfg.risk_profiles.keys());
898    let runtime_profile = first_alias(cfg.runtime_profiles.keys());
899
900    let Some(agent) = cfg.agents.get_mut(alias) else {
901        return;
902    };
903    if agent.model_provider.trim().is_empty()
904        && let Some(model_provider) = model_provider
905    {
906        agent.model_provider = model_provider.into();
907    }
908    if agent.risk_profile.trim().is_empty()
909        && let Some(risk_profile) = risk_profile
910    {
911        agent.risk_profile = risk_profile;
912    }
913    if agent.runtime_profile.trim().is_empty()
914        && let Some(runtime_profile) = runtime_profile
915    {
916        agent.runtime_profile = runtime_profile;
917    }
918}
919
920fn mark_section_completed(cfg: &mut zeroclaw_config::schema::Config, section: &str) {
921    if !cfg
922        .onboard_state
923        .completed_sections
924        .iter()
925        .any(|completed| completed == section)
926    {
927        cfg.onboard_state
928            .completed_sections
929            .push(section.to_string());
930        cfg.mark_dirty("onboard_state.completed_sections");
931    }
932}
933
934fn first_alias<'a>(aliases: impl Iterator<Item = &'a String>) -> Option<String> {
935    let mut aliases: Vec<&String> = aliases.collect();
936    aliases.sort();
937    aliases.first().map(|alias| (*alias).clone())
938}
939
940/// `tunnel`-flavored picker: same as `schema_walk_picker` plus a synthetic
941/// `none` entry at the top, marked active when the current `tunnel.tunnel_provider`
942/// matches. Mirrors the TUI's tunnel section.
943fn schema_walk_picker_with_none(
944    cfg: &zeroclaw_config::schema::Config,
945    section: &str,
946    active_prop_path: &str,
947) -> Vec<PickerItem> {
948    let active = cfg.get_prop(active_prop_path).unwrap_or_default();
949    let mut items = vec![PickerItem {
950        key: "none".to_string(),
951        label: "none".to_string(),
952        description: Some("Localhost only — no public tunnel.".to_string()),
953        badge: if active == "none" || active.is_empty() {
954            Some("active".to_string())
955        } else {
956            None
957        },
958    }];
959    let mut rest = schema_walk_picker(cfg, section);
960    // Re-mark the active one in the schema-walk results.
961    for item in &mut rest {
962        if item.key == active {
963            item.badge = Some("active".to_string());
964        }
965    }
966    items.extend(rest);
967    items
968}
969
970#[derive(Debug, Deserialize)]
971#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
972pub struct SectionItemPath {
973    pub section: String,
974    pub key: String,
975}
976
977/// `POST /api/config/sections/<section>/items/<key>` — instantiate the
978/// selected item in the live config (idempotent) and return the dotted
979/// prefix the frontend should fetch fields under.
980///
981/// Per-section dispatch:
982/// * `providers` → POST equivalent of `/api/config/map-key?path=providers.models&key=<key>`,
983///   then return `model_providers.<key>`.
984/// * `channels` → init_defaults under `channels.<key>`, return `channels.<key>`.
985/// * `memory` → set_prop `memory.backend = <key>`, return `memory`.
986/// * `tunnel` → set_prop `tunnel.tunnel_provider = <key>` (and init_defaults the
987///   subsection if `<key>` is not "none"), return `tunnel.<key>` (or `tunnel`
988///   for the `none` case).
989///
990/// The optional JSON body `{"alias": "<name>"}` names the entry being created,
991/// e.g. `"work"` for `model_providers.anthropic.work`. Omit to use `"default"`.
992#[derive(Debug, Default, Deserialize)]
993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
994pub struct SectionSelectBody {
995    pub alias: Option<String>,
996}
997
998pub async fn handle_section_select(
999    State(state): State<AppState>,
1000    headers: HeaderMap,
1001    axum::extract::Path(SectionItemPath { section, key }): axum::extract::Path<SectionItemPath>,
1002    body: Option<axum::extract::Json<SectionSelectBody>>,
1003) -> Response {
1004    if let Err(e) = require_auth(&state, &headers) {
1005        return e.into_response();
1006    }
1007
1008    let alias = body
1009        .and_then(|b| b.0.alias)
1010        .map(|s| s.trim().to_string())
1011        .filter(|s| !s.is_empty())
1012        .unwrap_or_else(|| "default".to_string());
1013
1014    let mut working = state.config.read().clone();
1015
1016    use zeroclaw_config::sections::Section;
1017    let Some(section_enum) = Section::from_key(&section) else {
1018        return error_response(
1019            ConfigApiError::new(
1020                ConfigApiCode::PathNotFound,
1021                format!("no picker semantics defined for section `{section}`"),
1022            )
1023            .with_path(section.as_str()),
1024        );
1025    };
1026
1027    let (fields_prefix, created) = match section_enum {
1028        Section::ModelProviders | Section::TtsProviders | Section::TranscriptionProviders => {
1029            // Two-tier typed-family path: outer bucket is the family
1030            // (`model_providers.<type>` etc.), inner key is the alias the
1031            // operator named. `create_map_key` is idempotent so re-selecting
1032            // an existing type/alias is a no-op for the bucket and just
1033            // returns the form prefix for the alias.
1034            let family = section_enum.as_str();
1035            let created = working
1036                .create_map_key(&format!("{family}.{key}"), &alias)
1037                .map_err(|msg| {
1038                    error_response(
1039                        ConfigApiError::new(
1040                            ConfigApiCode::PathNotFound,
1041                            format!("could not select {family} `{key}` alias `{alias}`: {msg}"),
1042                        )
1043                        .with_path(format!("{family}.{key}")),
1044                    )
1045                });
1046            let created = match created {
1047                Ok(c) => c,
1048                Err(resp) => return resp,
1049            };
1050            // Per-family typed configs derive their own default endpoint
1051            // URI via family traits at runtime construction time.
1052            (format!("{family}.{key}.{alias}"), created)
1053        }
1054        Section::Channels => {
1055            let created = working
1056                .create_map_key(&format!("channels.{key}"), &alias)
1057                .map_err(|msg| {
1058                    error_response(
1059                        ConfigApiError::new(
1060                            ConfigApiCode::PathNotFound,
1061                            format!("could not select channel `{key}` alias `{alias}`: {msg}"),
1062                        )
1063                        .with_path(format!("channels.{key}")),
1064                    )
1065                });
1066            let created = match created {
1067                Ok(c) => c,
1068                Err(resp) => return resp,
1069            };
1070            // The per-channel-type struct's `enabled` field defaults to
1071            // `false` for paste-safety (don't fire a listener on a
1072            // half-pasted block). For wizard-driven creation the operator
1073            // has just consciously added the alias, so flip
1074            // the new entry's `enabled` to true. Re-selecting an existing
1075            // alias is a no-op (created=false), so user-edited values are
1076            // never trampled.
1077            if created {
1078                let enabled_path = format!("channels.{key}.{alias}.enabled");
1079                if let Err(e) = working.set_prop_persistent(&enabled_path, "true") {
1080                    ::zeroclaw_log::record!(
1081                        WARN,
1082                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1083                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1084                            .with_attrs(
1085                                ::serde_json::json!({"path": enabled_path, "error": format!("{}", e)})
1086                            ),
1087                        "failed to default-enable newly created channel; operator must toggle manually"
1088                    );
1089                }
1090            }
1091            (format!("channels.{key}.{alias}"), created)
1092        }
1093        Section::Agents
1094        | Section::PeerGroups
1095        | Section::Cron
1096        | Section::McpBundles
1097        | Section::KnowledgeBundles
1098        | Section::SkillBundles
1099        | Section::RiskProfiles
1100        | Section::RuntimeProfiles => {
1101            // OneTierAliasMap: the URL path key IS the alias. One
1102            // `create_map_key("<section>", &key)` call works for every
1103            // operator-named HashMap section; create_map_key is
1104            // idempotent, so selecting an existing alias just returns
1105            // the form prefix without modifying anything.
1106            let section_key = section_enum.as_str();
1107            let created = working.create_map_key(section_key, &key).map_err(|msg| {
1108                error_response(
1109                    ConfigApiError::new(
1110                        ConfigApiCode::PathNotFound,
1111                        format!("could not select {section_key} alias `{key}`: {msg}"),
1112                    )
1113                    .with_path(section_key),
1114                )
1115            });
1116            let created = match created {
1117                Ok(c) => c,
1118                Err(resp) => return resp,
1119            };
1120            // Agents need a per-alias workspace dir on disk so the
1121            // PersonalityEditor and the runtime have somewhere to read
1122            // and write IDENTITY.md / SOUL.md / USER.md / etc.
1123            if created && matches!(section_enum, Section::Agents) {
1124                apply_first_run_agent_defaults(&mut working, &key);
1125                let workspace_dir = working.agent_workspace_dir(&key);
1126                if let Err(err) = tokio::fs::create_dir_all(&workspace_dir).await {
1127                    return error_response(
1128                        ConfigApiError::new(
1129                            ConfigApiCode::ValidationFailed,
1130                            format!(
1131                                "created agent `{key}` but failed to scaffold workspace at {}: {err}",
1132                                workspace_dir.display()
1133                            ),
1134                        )
1135                        .with_path(section_key),
1136                    );
1137                }
1138                if let Err(err) =
1139                    zeroclaw_config::schema::ensure_bootstrap_files(&workspace_dir).await
1140                {
1141                    ::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)");
1142                }
1143            }
1144            (format!("{section_key}.{key}"), created)
1145        }
1146        Section::Storage => {
1147            // Two-tier typed-family (`storage.<kind>.<alias>`) — same
1148            // shape and selection flow as model_providers / tts_providers /
1149            // transcription_providers. Outer bucket is the storage kind
1150            // (sqlite, postgres, qdrant, markdown, lucid); inner key is
1151            // the operator-named alias.
1152            let created = working
1153                .create_map_key(&format!("storage.{key}"), &alias)
1154                .map_err(|msg| {
1155                    error_response(
1156                        ConfigApiError::new(
1157                            ConfigApiCode::PathNotFound,
1158                            format!("could not select storage `{key}` alias `{alias}`: {msg}"),
1159                        )
1160                        .with_path(format!("storage.{key}")),
1161                    )
1162                });
1163            let created = match created {
1164                Ok(c) => c,
1165                Err(resp) => return resp,
1166            };
1167            mark_section_completed(&mut working, "storage");
1168            (format!("storage.{key}.{alias}"), created)
1169        }
1170        Section::Memory => {
1171            // Set memory.backend to the picked key. Fields_prefix points at
1172            // `memory` so the form renders the whole memory section
1173            // (the active backend's specific fields show up there).
1174            if let Err(e) = working.set_prop_persistent("memory.backend", &key) {
1175                return error_response(
1176                    ConfigApiError::new(
1177                        ConfigApiCode::ValidationFailed,
1178                        format!("could not set memory.backend = `{key}`: {e}"),
1179                    )
1180                    .with_path("memory.backend"),
1181                );
1182            }
1183            mark_section_completed(&mut working, "memory");
1184            ("memory".to_string(), true)
1185        }
1186        Section::Tunnel => {
1187            if let Err(e) = working.set_prop_persistent("tunnel.tunnel-provider", &key) {
1188                return error_response(
1189                    ConfigApiError::new(
1190                        ConfigApiCode::ValidationFailed,
1191                        format!("could not set tunnel.tunnel-provider = `{key}`: {e}"),
1192                    )
1193                    .with_path("tunnel.tunnel-provider"),
1194                );
1195            }
1196            let prefix = if key == "none" {
1197                "tunnel".to_string()
1198            } else {
1199                let p = format!("tunnel.{key}");
1200                working.init_defaults(Some(&p));
1201                p
1202            };
1203            (prefix, true)
1204        }
1205        Section::Hardware | Section::Mcp | Section::Skills | Section::QuickstartState => {
1206            return error_response(
1207                ConfigApiError::new(
1208                    ConfigApiCode::PathNotFound,
1209                    format!(
1210                        "section `{}` is a direct-form section with no picker; \
1211                         render fields via GET /api/config/list?prefix={}",
1212                        section_enum, section_enum
1213                    ),
1214                )
1215                .with_path(section_enum.as_str()),
1216            );
1217        }
1218    };
1219
1220    if created {
1221        working.mark_dirty(&fields_prefix);
1222    }
1223
1224    if let Err(e) = working.save_dirty().await {
1225        return error_response(ConfigApiError::new(
1226            ConfigApiCode::ReloadFailed,
1227            format!("save after select failed: {e}"),
1228        ));
1229    }
1230    *state.config.write() = working;
1231
1232    axum::Json(SelectItemResponse {
1233        fields_prefix,
1234        created,
1235    })
1236    .into_response()
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    /// Regression guard: every alias-bearing map the handler exposes must
1244    /// be reachable from `Config::get_map_keys` using the kebab-case path
1245    /// `build_agent_options` passes. Snake_case silently returns `None` →
1246    /// empty Vec → dashboard renders "No X configured yet" when X exists.
1247    /// This test drives the same code the gateway runs and would have
1248    /// caught the original bug. Adding a new alias-bearing field requires
1249    /// adding it here too.
1250    #[test]
1251    fn build_agent_options_returns_every_configured_alias() {
1252        let mut cfg = zeroclaw_config::schema::Config::default();
1253        cfg.create_map_key("providers.models.anthropic", "default")
1254            .unwrap();
1255        cfg.create_map_key("risk_profiles", "alpha_risk").unwrap();
1256        cfg.create_map_key("runtime_profiles", "alpha_runtime")
1257            .unwrap();
1258        cfg.create_map_key("skill_bundles", "alpha_skills").unwrap();
1259        cfg.create_map_key("knowledge_bundles", "alpha_knowledge")
1260            .unwrap();
1261        cfg.create_map_key("mcp_bundles", "alpha_mcp").unwrap();
1262        cfg.create_map_key("agents", "alpha_agent").unwrap();
1263
1264        let resp = build_agent_options(&cfg);
1265
1266        assert_eq!(resp.model_providers, vec!["anthropic.default".to_string()]);
1267        assert_eq!(resp.risk_profiles, vec!["alpha_risk".to_string()]);
1268        assert_eq!(resp.runtime_profiles, vec!["alpha_runtime".to_string()]);
1269        assert_eq!(resp.skill_bundles, vec!["alpha_skills".to_string()]);
1270        assert_eq!(resp.knowledge_bundles, vec!["alpha_knowledge".to_string()],);
1271        assert_eq!(resp.mcp_bundles, vec!["alpha_mcp".to_string()]);
1272        assert_eq!(resp.agents, vec!["alpha_agent".to_string()]);
1273    }
1274
1275    #[test]
1276    fn typed_provider_catalog_keys_create_snake_config_sections() {
1277        let mut cfg = zeroclaw_config::schema::Config::default();
1278        let cases = [
1279            ("providers.models", "atomic_chat"),
1280            ("providers.models", "gemini_cli"),
1281            ("providers.transcription", "local_whisper"),
1282        ];
1283
1284        for (family, key) in cases {
1285            let path = format!("{family}.{key}");
1286            cfg.create_map_key(&path, "default")
1287                .unwrap_or_else(|e| panic!("{key} should map to `{path}`: {e}"));
1288        }
1289
1290        assert!(
1291            cfg.providers.models.atomic_chat.contains_key("default"),
1292            "created Atomic Chat alias should land in the atomic_chat provider map",
1293        );
1294        assert!(
1295            cfg.providers.models.gemini_cli.contains_key("default"),
1296            "created Gemini CLI alias should land in the gemini_cli provider map",
1297        );
1298        assert!(
1299            cfg.providers
1300                .transcription
1301                .local_whisper
1302                .contains_key("default"),
1303            "created Local Whisper alias should land in the local_whisper provider map",
1304        );
1305    }
1306
1307    #[test]
1308    fn derive_section_status_requires_dispatchable_agent() {
1309        let mut cfg = zeroclaw_config::schema::Config::default();
1310        let resp = derive_section_status(&cfg);
1311        assert!(resp.needs_quickstart);
1312        assert_eq!(resp.reason, "fresh_install");
1313
1314        cfg.create_map_key("providers.models.anthropic", "default")
1315            .unwrap();
1316        let resp = derive_section_status(&cfg);
1317        assert!(
1318            resp.needs_quickstart,
1319            "provider configured without a bound agent must not flip needs_quickstart"
1320        );
1321        assert_eq!(resp.reason, "incomplete_agent");
1322        assert!(resp.has_partial_state);
1323
1324        cfg.create_map_key("risk_profiles", "default").unwrap();
1325        cfg.create_map_key("runtime_profiles", "default").unwrap();
1326        cfg.create_map_key("agents", "default").unwrap();
1327        let resp = derive_section_status(&cfg);
1328        assert!(
1329            resp.needs_quickstart,
1330            "agent without provider/profile bindings must still need onboarding"
1331        );
1332        assert_eq!(resp.reason, "incomplete_agent");
1333        assert!(
1334            resp.missing
1335                .iter()
1336                .any(|m| m == "Set a model provider for agent `default`.")
1337        );
1338
1339        let agent = cfg.agents.get_mut("default").unwrap();
1340        agent.model_provider = "anthropic.default".into();
1341        agent.risk_profile = "default".into();
1342        agent.runtime_profile = "default".into();
1343        let resp = derive_section_status(&cfg);
1344        assert!(
1345            resp.needs_quickstart,
1346            "provider alias without a selected model must still need onboarding"
1347        );
1348        assert!(
1349            resp.missing
1350                .iter()
1351                .any(|m| m == "Choose a model for model provider `anthropic.default`.")
1352        );
1353
1354        cfg.set_prop("providers.models.anthropic.default.model", "claude-sonnet")
1355            .unwrap();
1356        let resp = derive_section_status(&cfg);
1357        assert!(
1358            resp.needs_quickstart,
1359            "hosted provider alias without credential/auth must still need onboarding"
1360        );
1361        assert!(
1362            resp.missing
1363                .iter()
1364                .any(|m| m == "Set credential/auth for model provider `anthropic.default`.")
1365        );
1366
1367        cfg.set_prop("providers.models.anthropic.default.api_key", "sk-test")
1368            .unwrap();
1369        let resp = derive_section_status(&cfg);
1370        assert!(!resp.needs_quickstart);
1371        assert_eq!(resp.reason, "has_dispatchable_agent");
1372        assert!(!resp.has_partial_state || resp.missing.is_empty());
1373    }
1374
1375    #[test]
1376    fn derive_section_status_completed_sections_without_dispatchable_agent_stays_pending() {
1377        let mut cfg = zeroclaw_config::schema::Config::default();
1378        cfg.onboard_state
1379            .completed_sections
1380            .push("providers.models".into());
1381        let resp = derive_section_status(&cfg);
1382        assert!(
1383            resp.needs_quickstart,
1384            "completed_sections marker without a dispatchable agent must NOT flip the redirect"
1385        );
1386        assert_eq!(resp.reason, "incomplete_agent");
1387    }
1388
1389    #[test]
1390    fn apply_first_run_agent_defaults_binds_existing_provider_and_profiles() {
1391        let mut cfg = zeroclaw_config::schema::Config::default();
1392        cfg.create_map_key("providers.models.anthropic", "work")
1393            .unwrap();
1394        cfg.create_map_key("risk_profiles", "default").unwrap();
1395        cfg.create_map_key("runtime_profiles", "deep_work").unwrap();
1396        cfg.create_map_key("agents", "default").unwrap();
1397
1398        apply_first_run_agent_defaults(&mut cfg, "default");
1399
1400        let agent = cfg.agents.get("default").unwrap();
1401        assert_eq!(agent.model_provider.as_str(), "anthropic.work");
1402        assert_eq!(agent.risk_profile, "default");
1403        assert_eq!(agent.runtime_profile, "deep_work");
1404    }
1405
1406    #[test]
1407    fn memory_section_ready_tracks_onboarding_progress_not_default_backend() {
1408        let cfg = zeroclaw_config::schema::Config::default();
1409        assert!(
1410            !section_ready(&cfg, "memory", false),
1411            "fresh onboarding should not show Memory checked merely because a default backend exists"
1412        );
1413        assert!(
1414            section_ready(&cfg, "memory", true),
1415            "Memory should show checked after the user has advanced through that section"
1416        );
1417    }
1418
1419    fn empty_cfg() -> zeroclaw_config::schema::Config {
1420        zeroclaw_config::schema::Config::default()
1421    }
1422
1423    #[test]
1424    fn handle_sections_derives_every_top_level_field_from_schema() {
1425        // Regression: the section list must be schema-driven, not the old
1426        // hardcoded 6. Adding a new top-level field to `Config` should make
1427        // it appear here automatically.
1428        let cfg = empty_cfg();
1429        let mut roots: std::collections::BTreeSet<String> = cfg
1430            .prop_fields()
1431            .iter()
1432            .filter_map(|f| f.name.split('.').next().map(str::to_string))
1433            .collect();
1434        // Mirror handle_sections: map-keyed sections surface even when
1435        // their HashMap is empty (prop_fields only emits paths for
1436        // populated entries).
1437        for s in zeroclaw_config::schema::Config::map_key_sections() {
1438            if let Some(first) = s.path.split('.').next() {
1439                roots.insert(first.to_string());
1440            }
1441        }
1442        for hidden in HIDDEN_TOP_LEVEL {
1443            roots.remove(*hidden);
1444        }
1445        // The 5 onboarding sections must still be in the derived set.
1446        for required in ["providers", "channels", "memory", "hardware", "tunnel"] {
1447            assert!(
1448                roots.contains(required),
1449                "derived sections must include onboarding section `{required}`; got {roots:?}",
1450            );
1451        }
1452        // Plus a sample of the runtime sections that used to be invisible.
1453        for runtime in ["gateway", "observability", "scheduler", "security"] {
1454            assert!(
1455                roots.contains(runtime),
1456                "derived sections must include runtime section `{runtime}`; got {roots:?}",
1457            );
1458        }
1459        // System / housekeeping fields must NOT surface.
1460        for hidden in HIDDEN_TOP_LEVEL {
1461            assert!(
1462                !roots.contains(*hidden),
1463                "hidden top-level `{hidden}` must not appear",
1464            );
1465        }
1466        for hidden in ["onboard_state", "onboard-state"] {
1467            assert!(
1468                !roots.contains(hidden),
1469                "onboarding bookkeeping root `{hidden}` must not appear",
1470            );
1471        }
1472    }
1473
1474    #[test]
1475    fn channels_select_initializes_subsection_so_set_prop_works() {
1476        // Regression for the channels init/set flow: after
1477        // handle_section_select for channels/matrix, the in-memory config
1478        // must have channels.matrix.<alias> so a subsequent set_prop on
1479        // channels.matrix.* succeeds rather than bailing "Unknown property".
1480        // Uses create_map_key directly (the synchronous core of the select
1481        // endpoint) to keep the test free of HTTP machinery.
1482        let mut cfg = empty_cfg();
1483        assert!(cfg.channels.matrix.is_empty(), "fresh config: matrix unset");
1484
1485        cfg.create_map_key("channels.matrix", "mymatrixalias")
1486            .expect("create_map_key must succeed for channels.matrix");
1487        assert!(
1488            cfg.channels.matrix.contains_key("mymatrixalias"),
1489            "channels.matrix must have alias after create_map_key",
1490        );
1491
1492        // The form would issue a PATCH whose set_prop call hits this path.
1493        cfg.set_prop(
1494            "channels.matrix.mymatrixalias.allowed_rooms",
1495            r#"["alice","bob"]"#,
1496        )
1497        .expect("set_prop on initialized matrix subsection must succeed");
1498        assert_eq!(
1499            cfg.channels
1500                .matrix
1501                .get("mymatrixalias")
1502                .unwrap()
1503                .allowed_rooms,
1504            vec!["alice".to_string(), "bob".to_string()],
1505        );
1506    }
1507
1508    #[test]
1509    fn providers_picker_sources_from_list_providers() {
1510        // Single source of truth: zeroclaw_providers::list_model_providers().
1511        // Anthropic / OpenAI / OpenRouter must surface in the picker.
1512        let cfg = empty_cfg();
1513        let items = providers_picker(&cfg);
1514        let names: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1515        assert!(
1516            names.contains(&"anthropic"),
1517            "expected anthropic in picker, got: {names:?}"
1518        );
1519        assert!(names.contains(&"openai"), "expected openai in picker");
1520        assert!(
1521            names.contains(&"openrouter"),
1522            "expected openrouter in picker"
1523        );
1524
1525        // Display name is human-readable, not the canonical key.
1526        let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
1527        assert_eq!(anthropic.label, "Anthropic");
1528
1529        // Local-only model_providers carry a description hint.
1530        let local = items.iter().find(|i| i.description.is_some());
1531        assert!(
1532            local.is_some(),
1533            "at least one model_provider should be marked local"
1534        );
1535
1536        // Empty config has no model_provider aliases — no badges yet.
1537        assert!(
1538            items.iter().all(|i| i.badge.is_none()),
1539            "fresh config shouldn't mark any model_provider as present"
1540        );
1541    }
1542
1543    #[test]
1544    fn providers_picker_marks_alias_readiness() {
1545        // Typed-family layout: each canonical family is a map-keyed
1546        // sub-section at `model_providers.<family>` whose entries are
1547        // operator-named aliases. Creating the alias alone is not enough
1548        // for chat dispatch; it still needs model + credential/auth.
1549        let mut cfg = empty_cfg();
1550        cfg.create_map_key("providers.models.anthropic", "default")
1551            .expect("create_map_key");
1552        let items = providers_picker(&cfg);
1553        let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
1554        assert_eq!(
1555            anthropic.badge.as_deref(),
1556            Some("needs setup"),
1557            "anthropic should need setup after adding an empty alias"
1558        );
1559
1560        cfg.set_prop(
1561            "providers.models.anthropic.default.model",
1562            "claude-sonnet-4-5",
1563        )
1564        .expect("set model");
1565        cfg.set_prop("providers.models.anthropic.default.api_key", "sk-test")
1566            .expect("set api key");
1567        let items = providers_picker(&cfg);
1568        let anthropic = items.iter().find(|i| i.key == "anthropic").unwrap();
1569        assert_eq!(
1570            anthropic.badge.as_deref(),
1571            Some("configured"),
1572            "anthropic should be marked configured once required chat fields are present"
1573        );
1574    }
1575
1576    #[test]
1577    fn memory_picker_sources_from_selectable_backends() {
1578        let cfg = empty_cfg();
1579        let items = memory_picker(&cfg);
1580        // Mirrors zeroclaw_memory::selectable_memory_backends() exactly.
1581        let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1582        assert!(keys.contains(&"sqlite"));
1583        assert!(keys.contains(&"none"));
1584        // Fresh onboarding should not imply the user selected the default.
1585        let active = items.iter().find(|i| i.badge.as_deref() == Some("active"));
1586        assert!(
1587            active.is_none(),
1588            "fresh onboarding should not mark a memory backend active before the user confirms the step"
1589        );
1590    }
1591
1592    #[test]
1593    fn channels_picker_walks_schema_via_init_defaults() {
1594        // Pure schema discovery — same trick the TUI uses. Whatever channels
1595        // the build has compiled in (matrix / discord / slack / etc.) appears
1596        // in the picker without any hand-maintained list. Test asserts a
1597        // representative sample compiled into the default `ci-all` build.
1598        let cfg = empty_cfg();
1599        let items = schema_walk_picker(&cfg, "channels");
1600        let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1601        assert!(!keys.is_empty(), "channel picker must not be empty");
1602        // Channels that are unconditionally compiled (no feature gate)
1603        // should always appear:
1604        for expected in ["telegram", "slack", "discord"] {
1605            assert!(
1606                keys.contains(&expected),
1607                "expected `{expected}` in channels picker, got: {keys:?}"
1608            );
1609        }
1610        // Fresh config — nothing configured yet.
1611        assert!(
1612            items.iter().all(|i| i.badge.is_none()),
1613            "fresh config shouldn't mark any channel as configured"
1614        );
1615    }
1616
1617    #[test]
1618    fn channels_picker_marks_configured_after_create_map_key() {
1619        let mut cfg = empty_cfg();
1620        cfg.create_map_key("channels.matrix", "mymatrixalias")
1621            .expect("create_map_key must succeed for channels.matrix");
1622        let items = schema_walk_picker(&cfg, "channels");
1623        let matrix = items.iter().find(|i| i.key == "matrix").unwrap();
1624        assert_eq!(
1625            matrix.badge.as_deref(),
1626            Some("configured"),
1627            "matrix should be marked configured after create_map_key"
1628        );
1629    }
1630
1631    #[test]
1632    fn tunnel_picker_includes_synthetic_none() {
1633        let cfg = empty_cfg();
1634        let items = schema_walk_picker_with_none(&cfg, "tunnel", "tunnel.tunnel-provider");
1635        assert_eq!(
1636            items[0].key, "none",
1637            "`none` must be the first entry in the tunnel picker"
1638        );
1639        // `none` is the active default for a fresh config.
1640        assert_eq!(items[0].badge.as_deref(), Some("active"));
1641    }
1642
1643    /// Empty OneTierAliasMap section yields zero picker items. No
1644    /// closed-kind catalog applies for these sections — only operator-defined
1645    /// aliases populate the picker. Section wire keys are kebab-case
1646    /// because the Configurable derive runs each field name through
1647    /// `snake_to_kebab` when registering map-key paths.
1648    #[test]
1649    fn one_tier_alias_map_picker_is_empty_for_unconfigured_section() {
1650        let cfg = empty_cfg();
1651        for section in [
1652            "peer_groups",
1653            "cron",
1654            "mcp_bundles",
1655            "knowledge_bundles",
1656            "skill_bundles",
1657            "risk_profiles",
1658            "runtime_profiles",
1659        ] {
1660            let items = one_tier_alias_map_picker(&cfg, section);
1661            assert!(
1662                items.is_empty(),
1663                "`{section}` picker must be empty on a fresh config, got: {:?}",
1664                items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(),
1665            );
1666        }
1667    }
1668
1669    /// After `create_map_key("<kebab-section>", "<alias>")`, the picker
1670    /// surfaces the alias as a `configured` entry. Same shape applies
1671    /// to every OneTierAliasMap section — the picker is generic over
1672    /// the prefix.
1673    #[test]
1674    fn one_tier_alias_map_picker_surfaces_created_aliases() {
1675        let cases: &[(&str, &str)] = &[
1676            ("peer_groups", "team_chat"),
1677            ("cron", "daily_brief"),
1678            ("mcp_bundles", "core_tools"),
1679            ("knowledge_bundles", "house_docs"),
1680            ("skill_bundles", "ops_skills"),
1681            ("risk_profiles", "tight"),
1682            ("runtime_profiles", "fast_model"),
1683        ];
1684        for (section, alias) in cases {
1685            let mut cfg = empty_cfg();
1686            cfg.create_map_key(section, alias)
1687                .unwrap_or_else(|e| panic!("create_map_key({section}, {alias}) failed: {e}"));
1688            let items = one_tier_alias_map_picker(&cfg, section);
1689            assert!(
1690                items.iter().any(|i| i.key == *alias),
1691                "`{section}` picker should surface `{alias}` after create_map_key; got: {:?}",
1692                items.iter().map(|i| i.key.as_str()).collect::<Vec<_>>(),
1693            );
1694            let entry = items.iter().find(|i| i.key == *alias).unwrap();
1695            assert_eq!(
1696                entry.badge.as_deref(),
1697                Some("configured"),
1698                "`{section}.{alias}` should be badged `configured`",
1699            );
1700        }
1701    }
1702
1703    /// Exhaustive picker dispatch: every [`Section`] variant must
1704    /// resolve through `picker_items_for` without panic. DirectForm
1705    /// sections (Workspace, Hardware, Mcp) return the
1706    /// `PickerDispatch::DirectForm` sentinel; every other section
1707    /// returns at least zero items. Loops over the wizard order plus
1708    /// every explorer-only variant — adding a new Section variant
1709    /// fails to compile until it gets a routing arm in
1710    /// `picker_items_for`.
1711    #[test]
1712    fn picker_dispatch_covers_every_section_variant() {
1713        use zeroclaw_config::sections::Section;
1714        let cfg = empty_cfg();
1715        // The full Section surface = wizard steps + explorer-only.
1716        // Spelling them out here pins both groups, so adding a row to
1717        // the `sections!` macro forces an update here too.
1718        let all: &[Section] = &[
1719            Section::ModelProviders,
1720            Section::TtsProviders,
1721            Section::TranscriptionProviders,
1722            Section::Channels,
1723            Section::Memory,
1724            Section::Hardware,
1725            Section::Tunnel,
1726            Section::Agents,
1727            Section::PeerGroups,
1728            Section::Storage,
1729            Section::Cron,
1730            Section::Mcp,
1731            Section::McpBundles,
1732            Section::KnowledgeBundles,
1733            Section::SkillBundles,
1734            Section::RiskProfiles,
1735            Section::RuntimeProfiles,
1736        ];
1737        let direct_form = [Section::Hardware, Section::Mcp];
1738        for section in all {
1739            match picker_items_for(*section, &cfg) {
1740                PickerDispatch::Items(_items) => {
1741                    assert!(
1742                        !direct_form.contains(section),
1743                        "{section:?} is marked DirectForm but dispatched to Items",
1744                    );
1745                }
1746                PickerDispatch::DirectForm => {
1747                    assert!(
1748                        direct_form.contains(section),
1749                        "{section:?} returned DirectForm but is not in the DirectForm set; \
1750                         either give it a picker arm or add it to the DirectForm list",
1751                    );
1752                }
1753            }
1754        }
1755    }
1756
1757    /// Storage is `[storage.<kind>.<alias>]` — two-tier typed-family
1758    /// shape, served by the storage picker. The picker
1759    /// surfaces the 5 storage kinds (sqlite, postgres, qdrant,
1760    /// markdown, lucid) regardless of which aliases exist, and badges
1761    /// the kind `created` once any alias under it is created.
1762    #[test]
1763    fn storage_picker_lists_all_kinds_and_marks_created() {
1764        let cfg = empty_cfg();
1765        let items = storage_picker(&cfg);
1766        let keys: Vec<&str> = items.iter().map(|i| i.key.as_str()).collect();
1767        for expected in ["sqlite", "postgres", "qdrant", "markdown", "lucid"] {
1768            assert!(
1769                keys.contains(&expected),
1770                "storage picker must list `{expected}`, got: {keys:?}",
1771            );
1772        }
1773        // Fresh config — no kind should be badged.
1774        assert!(
1775            items.iter().all(|i| i.badge.is_none()),
1776            "fresh config: no storage kind should be marked configured",
1777        );
1778
1779        // Create a sqlite instance; the sqlite row should flip to configured.
1780        let mut cfg2 = empty_cfg();
1781        cfg2.create_map_key("storage.sqlite", "primary")
1782            .expect("create_map_key(storage.sqlite, primary) must succeed");
1783        let items = storage_picker(&cfg2);
1784        let sqlite = items.iter().find(|i| i.key == "sqlite").unwrap();
1785        assert_eq!(
1786            sqlite.badge.as_deref(),
1787            Some("created"),
1788            "storage.sqlite should be marked created after adding an alias",
1789        );
1790        assert!(
1791            sqlite.description.is_some(),
1792            "storage picker should explain each backend tradeoff",
1793        );
1794    }
1795}