Skip to main content

zeroclaw_config/
sections.rs

1//! Onboarding sections surface — a flat ordered set of [`Section`]s the
2//! operator walks (new install) or scans (returning user) to configure
3//! a working ZeroClaw deployment.
4//!
5//! Every fact about a section (its enum variant, its on-the-wire key,
6//! its UI shape, its help blurb, its canonical position) lives in ONE
7//! table — the [`sections!`] invocation below. The macro expands that
8//! table into the [`Section`] enum, every per-variant `match` helper,
9//! and the [`ONBOARDING_SECTIONS`] const, so adding a section is exactly
10//! one row, no hand-listed variant set anywhere else.
11//!
12//! Consumers (CLI runtime, gateway, dashboard) dispatch off this enum;
13//! drift is a compile error.
14
15use serde::{Deserialize, Serialize};
16
17/// UI rendering shape for a section. Drives picker / form dispatch on
18/// both the `/onboard` step-through and the `/config` explorer.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
20#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
21#[serde(rename_all = "snake_case")]
22pub enum SectionShape {
23    /// `<section>` renders a schema-driven form with no picker step.
24    DirectForm,
25    /// `<section>.<alias>` map of structured entries; the section page
26    /// shows an alias list with `+ Add` and clicking an alias opens its
27    /// schema form.
28    OneTierAliasMap,
29    /// `<section>.<type>.<alias>` two-tier map. Picker chooses `<type>`,
30    /// alias-list step chooses `<alias>`, then the schema form opens.
31    TypedFamilyMap,
32    /// Single non-alias choice (memory backend, tunnel provider). Picker
33    /// flips a top-level field, then the schema form for the chosen
34    /// backend/provider renders.
35    BackendPicker,
36}
37
38/// Single source of truth for every pickable config section. Each row
39/// maps 1:1 to a dashboard `/config/<key>` page, a CLI
40/// `zeroclaw onboard <key>` subcommand, and the gateway picker handler.
41/// Adding/removing a section is one row here and every consumer's
42/// `match` either compiles cleanly or fails with an exhaustiveness
43/// error pointing at exactly what needs an arm.
44///
45/// Row order is the canonical order operators see in the dashboard
46/// and walk through in the CLI. It is dependency-correct: every
47/// downstream alias reference an Agent carries (model_provider,
48/// risk_profile, runtime_profile, channels, *_bundles) appears earlier
49/// in the list than [`Section::Agents`], so walking top-to-bottom
50/// never produces a dangling reference.
51macro_rules! sections {
52    (
53        $(
54            $var:ident => {
55                key:   $key:literal,
56                shape: $shape:ident,
57                help:  $help:expr $(,)?
58            }
59        ),+ $(,)?
60    ) => {
61        /// One pickable section. The variant ordering follows the
62        /// `sections!` macro invocation.
63        ///
64        /// With the `clap` feature on, this enum doubles as the
65        /// `zeroclaw onboard <section>` clap subcommand — no separate
66        /// mirror enum in the binary crate.
67        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
68        #[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
69        #[cfg_attr(feature = "clap", derive(clap::Subcommand))]
70        #[serde(rename_all = "snake_case")]
71        pub enum Section {
72            $(
73                // Both clap (`--help`) and our runtime `help()` method
74                // need the same blurb; emit it once as a doc comment so
75                // the two surfaces share a single string per variant.
76                #[doc = $help]
77                #[cfg_attr(feature = "clap", command(name = $key))]
78                $var,
79            )+
80        }
81
82        impl Section {
83            /// Stable on-the-wire key. Also serves as the TOML
84            /// top-level prefix (e.g. `providers.models.<type>.<alias>`),
85            /// the `/onboard/<key>` URL segment, and the
86            /// `SectionInfo.key` field returned by the gateway.
87            #[must_use]
88            pub const fn as_str(self) -> &'static str {
89                match self {
90                    $( Self::$var => $key, )+
91                }
92            }
93
94            /// Editor shape — the dashboard and the CLI both
95            /// dispatch off this so the same component lights up for
96            /// the same section in both surfaces.
97            #[must_use]
98            pub const fn shape(self) -> SectionShape {
99                match self {
100                    $( Self::$var => SectionShape::$shape, )+
101                }
102            }
103
104            /// Per-section help blurb — single source of truth for
105            /// the copy shown above the section's picker / form on
106            /// every surface (CLI `ui.note(...)`, TUI heading,
107            /// dashboard `SectionInfo.help`).
108            #[must_use]
109            pub const fn help(self) -> &'static str {
110                match self {
111                    $( Self::$var => $help, )+
112                }
113            }
114
115            /// Parse a stable wire key, tolerating both the snake and
116            /// kebab spellings of any section. The schema mixes the two:
117            /// `model_providers` (snake) and `peer-groups` (kebab) are
118            /// both valid wire forms produced elsewhere in the codebase.
119            /// Callers (dashboard URL routing, gateway picker dispatch,
120            /// CLI clap subcommands) can pass either form; `from_key`
121            /// resolves to the same variant. Returns `None` for keys
122            /// outside the known section table. Named `from_key` rather
123            /// than `from_str` so clippy doesn't flag it as confusable
124            /// with `std::str::FromStr` (parse failure is `None`, not
125            /// `Err(_)`).
126            #[must_use]
127            pub fn from_key(s: &str) -> Option<Self> {
128                let try_match = |s: &str| -> Option<Self> {
129                    match s {
130                        $( $key => Some(Self::$var), )+
131                        _ => None,
132                    }
133                };
134                if let Some(v) = try_match(s) {
135                    return Some(v);
136                }
137                if s.contains('_')
138                    && let Some(v) = try_match(&s.replace('_', "-"))
139                {
140                    return Some(v);
141                }
142                if s.contains('-')
143                    && let Some(v) = try_match(&s.replace('-', "_"))
144                {
145                    return Some(v);
146                }
147                None
148            }
149        }
150
151        /// Canonical ordering of onboarding sections, walked by
152        /// `/onboard` and the `zeroclaw onboard` CLI in order. The
153        /// dashboard renders Next/Finish navigation against this list.
154        /// Every consumer that needs section ordering reads from here.
155        pub const ONBOARDING_SECTIONS: &[Section] = &[ $( Section::$var ),+ ];
156    };
157}
158
159sections! {
160    // Tier 1 — Brain. An agent cannot think without a model provider.
161    ModelProviders => {
162        key:   "providers.models",
163        shape: TypedFamilyMap,
164        help:  "Pick a model provider to configure (Anthropic, OpenAI, OpenRouter, \
165                Ollama, custom OpenAI-compatible gateways, etc.). Multiple aliases per \
166                provider are supported — e.g. anthropic.production and anthropic.dev \
167                can coexist.",
168    },
169
170    // Tier 2 — Behavior shape. agents.<alias>.risk_profile and
171    // .runtime_profile are required alias refs; both must exist before
172    // an Agent that points at them can resolve.
173    RiskProfiles => {
174        key:   "risk-profiles",
175        shape: OneTierAliasMap,
176        help:  "Named risk profiles binding allowlists, denylists, and approval \
177                thresholds. Agents reference one via `agents.<alias>.risk_profile`.",
178    },
179    RuntimeProfiles => {
180        key:   "runtime-profiles",
181        shape: OneTierAliasMap,
182        help:  "Named runtime tuning profiles (token limits, retry policy, timeouts). \
183                Agents reference one via `agents.<alias>.runtime_profile`.",
184    },
185
186    // Tier 3 — Storage. memory.backend points at a storage.<type>.<alias>
187    // instance, so storage must exist first.
188    Storage => {
189        key:   "storage",
190        shape: TypedFamilyMap,
191        help:  "SQLite is the safe default for single-node installs (file-based, \
192                zero-config, no extra services). Pick Postgres for shared or \
193                multi-instance deployments, Qdrant for vector search, Markdown or \
194                Lucid for human-readable files. Each backend supports multiple \
195                aliased instances; agents reference them via `memory.storage_ref`.",
196    },
197    Memory => {
198        key:   "memory",
199        shape: BackendPicker,
200        help:  "Persistent memory backend. SQLite is the default; pick `none` to \
201                disable long-term recall entirely.",
202    },
203
204    // Tier 4 — Capabilities. Bundles that agents reference via
205    // skill_bundles / mcp_bundle / knowledge_bundles.
206    Skills => {
207        key:   "skills",
208        shape: DirectForm,
209        help:  "Skills tool settings — where skill markdown lives on disk (defaults \
210                to the data dir), and how the skills loader handles community \
211                repositories. Add skill BUNDLES under `skill-bundles` below.",
212    },
213    SkillBundles => {
214        key:   "skill-bundles",
215        shape: OneTierAliasMap,
216        help:  "Named bundles of skill files. Agents reference a bundle to load a \
217                set of capabilities at startup.",
218    },
219    Mcp => {
220        key:   "mcp",
221        shape: DirectForm,
222        help:  "Model Context Protocol settings. Toggle `enabled` and pick deferred \
223                or eager loading. Individual MCP servers live under `mcp.servers[]`.",
224    },
225    McpBundles => {
226        key:   "mcp-bundles",
227        shape: OneTierAliasMap,
228        help:  "Named bundles of MCP servers. Agents reference a bundle to pull in \
229                a set of MCP tools as one unit.",
230    },
231    KnowledgeBundles => {
232        key:   "knowledge-bundles",
233        shape: OneTierAliasMap,
234        help:  "Named bundles of knowledge sources (RAG indexes, doc folders). Agents \
235                reference a bundle to surface relevant snippets at inference time.",
236    },
237
238    // Tier 5 — Modal IO. Optional voice in/out providers.
239    TtsProviders => {
240        key:   "providers.tts",
241        shape: TypedFamilyMap,
242        help:  "Text-to-speech providers (OpenAI, ElevenLabs, Google, Edge, Piper). \
243                Configure one per voice / language; agents reference them by alias.",
244    },
245    TranscriptionProviders => {
246        key:   "providers.transcription",
247        shape: TypedFamilyMap,
248        help:  "Speech-to-text providers (OpenAI Whisper, Groq, Deepgram, AssemblyAI, \
249                Google, local Whisper). Configure one per pipeline; agents reference \
250                them by alias.",
251    },
252
253    // Tier 6 — Channels. How agents listen. agents.<alias>.channels
254    // references channel aliases, so channels must exist first.
255    Channels => {
256        key:   "channels",
257        shape: TypedFamilyMap,
258        help:  "Pick which chat platforms ZeroClaw should listen on. You can \
259                configure multiple — each channel gets its own alias.",
260    },
261    Hardware => {
262        key:   "hardware",
263        shape: DirectForm,
264        help:  "Optional: hardware peripherals (Arduino, STM32, GPIO, etc.). \
265                Skip if you don't need them.",
266    },
267
268    // Tier 7 — Bind. Pulls tiers 1–6 together. Every alias ref an
269    // Agent carries exists by this point.
270    // Personality is intentionally NOT a top-level section in v0.8.0 —
271    // markdown personality files live per-agent and surface inside the
272    // agent edit form.
273    Agents => {
274        key:   "agents",
275        shape: OneTierAliasMap,
276        help:  "An agent binds a model provider, profiles, bundles, and channels \
277                into one dispatchable unit. Add one per persona; reuse the same \
278                alias across channels to share state.",
279    },
280
281    // Tier 8 — Topology. Multi-agent relationships and scheduled
282    // invocations; both reference agents and must follow Agents.
283    PeerGroups => {
284        key:   "peer-groups",
285        shape: OneTierAliasMap,
286        help:  "Named groups binding a channel, member agents, and external peers. \
287                Mutual opt-in: two agents become peers only when both appear in the \
288                same group's `agents` list.",
289    },
290    Cron => {
291        key:   "cron",
292        shape: OneTierAliasMap,
293        help:  "Scheduled tasks. Each cron entry binds a schedule expression to a \
294                prompt, channel, and target.",
295    },
296
297    // Tier 9 — Exposure. Gateway public-internet exposure. Only
298    // relevant when a webhook-mode channel needs a public URL.
299    Tunnel => {
300        key:   "tunnel",
301        shape: BackendPicker,
302        help:  "Optional: expose your gateway over the public internet via Cloudflare \
303                or ngrok. Pick `none` to keep it localhost-only.",
304    },
305}
306
307impl std::fmt::Display for Section {
308    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
309        f.write_str(self.as_str())
310    }
311}
312
313/// Canonical-order index of `section` in [`ONBOARDING_SECTIONS`].
314/// Always `Some` for any valid `Section` variant — the const includes
315/// every variant by construction. Returns `Option` for API symmetry
316/// with [`section_index_for_key`], which can fail on unknown keys.
317#[must_use]
318pub fn section_index(section: Section) -> Option<usize> {
319    ONBOARDING_SECTIONS.iter().position(|s| *s == section)
320}
321
322/// Canonical-order index for a wire key, or `None` if the key isn't a
323/// known [`Section`]. Used by gateway / dashboard sort comparators that
324/// take string keys from the HTTP layer.
325#[must_use]
326pub fn section_index_for_key(key: &str) -> Option<usize> {
327    Section::from_key(key).and_then(section_index)
328}
329
330/// True when `key` parses as a known [`Section`].
331#[must_use]
332pub fn is_known_section(key: &str) -> bool {
333    Section::from_key(key).is_some()
334}
335
336/// Help blurb for a section key, covering both `Section` variants and
337/// the long tail of top-level `Config` fields the dashboard / TUI config
338/// editor surface (gateway, scheduler, observability, …). Single source
339/// of truth shared by every surface — the gateway sidebar, the CLI
340/// onboard flow, and the future TUI config editor all call this rather
341/// than maintaining parallel tables.
342///
343/// Resolution order:
344/// 1. `Section` variants (curated `help` text next to the variant
345///    declaration in the `sections!` macro).
346/// 2. The `Config` struct's `#[nested]` field-level `///` docstring,
347///    harvested by the `Configurable` derive into
348///    `Config::nested_section_help`. This is what makes adding a new
349///    top-level section a one-line schema change with no parallel
350///    help table to update.
351///
352/// Returns `""` for keys without a docstring so callers can decide
353/// whether to omit the help row or show a fallback.
354#[must_use]
355pub fn section_help(key: &str) -> &'static str {
356    if let Some(s) = Section::from_key(key) {
357        return s.help();
358    }
359    crate::schema::Config::nested_section_help(key).unwrap_or("")
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    /// Round-trip every entry in the canonical list. `from_key`,
367    /// `as_str`, `section_index`, and `ONBOARDING_SECTIONS` are all
368    /// generated from the same `sections!` row, so this test exercises
369    /// the table — adding a row that breaks any of them fails here
370    /// without listing variants by hand.
371    #[test]
372    fn sections_round_trip() {
373        for s in ONBOARDING_SECTIONS {
374            assert_eq!(Section::from_key(s.as_str()), Some(*s), "{s} round-trip");
375            assert_eq!(
376                section_index(*s),
377                Some(ONBOARDING_SECTIONS.iter().position(|x| x == s).unwrap()),
378            );
379        }
380        assert_eq!(Section::from_key("gateway"), None);
381        assert_eq!(Section::from_key("not_a_section"), None);
382    }
383
384    /// Every section the dashboard URL surface points at must resolve
385    /// through `Section::from_key`. The dashboard URL form is kebab-case
386    /// (`peer-groups`), the canonical wire form may be snake_case
387    /// (`peer_groups`); both must parse to the same variant.
388    #[test]
389    fn dashboard_url_sections_round_trip_kebab_and_snake() {
390        let kebab_then_snake: &[(&str, &str, Section)] = &[
391            ("peer-groups", "peer_groups", Section::PeerGroups),
392            ("mcp-bundles", "mcp_bundles", Section::McpBundles),
393            (
394                "knowledge-bundles",
395                "knowledge_bundles",
396                Section::KnowledgeBundles,
397            ),
398            ("skill-bundles", "skill_bundles", Section::SkillBundles),
399            ("risk-profiles", "risk_profiles", Section::RiskProfiles),
400            (
401                "runtime-profiles",
402                "runtime_profiles",
403                Section::RuntimeProfiles,
404            ),
405            ("storage", "storage", Section::Storage),
406            ("cron", "cron", Section::Cron),
407            ("mcp", "mcp", Section::Mcp),
408        ];
409        for (kebab, snake, expected) in kebab_then_snake {
410            assert_eq!(
411                Section::from_key(kebab),
412                Some(*expected),
413                "kebab `{kebab}` should resolve to {expected:?}",
414            );
415            assert_eq!(
416                Section::from_key(snake),
417                Some(*expected),
418                "snake `{snake}` should resolve to {expected:?}",
419            );
420            assert!(
421                ONBOARDING_SECTIONS.contains(expected),
422                "{expected:?} must be in ONBOARDING_SECTIONS",
423            );
424        }
425    }
426
427    /// Every OneTierAliasMap section's wire key must appear verbatim
428    /// in `Config::map_key_sections()`. That table is what
429    /// `Config::create_map_key` dispatches off, so a mismatch silently
430    /// breaks the dashboard's `+ Add` affordance.
431    #[test]
432    fn alias_map_section_wire_keys_match_map_key_sections() {
433        use crate::schema::Config;
434        let sections = Config::map_key_sections();
435        let paths: std::collections::BTreeSet<&str> = sections.iter().map(|s| s.path).collect();
436        let alias_map_sections = [
437            Section::PeerGroups,
438            Section::Cron,
439            Section::McpBundles,
440            Section::KnowledgeBundles,
441            Section::SkillBundles,
442            Section::RiskProfiles,
443            Section::RuntimeProfiles,
444        ];
445        for section in alias_map_sections {
446            assert!(
447                paths.contains(section.as_str()),
448                "`Section::{section:?}.as_str() = {}` is not in map_key_sections; the \
449                 picker's create_map_key call site will fail. Registered paths: {paths:?}",
450                section.as_str(),
451            );
452        }
453    }
454
455    /// Canonical order is dependency-correct: every Section that
456    /// `AliasedAgentConfig` references through an alias field appears
457    /// earlier in the list than `Section::Agents`. Walking
458    /// `ONBOARDING_SECTIONS` top-to-bottom never asks the operator to
459    /// configure an Agent before the things it has to bind to exist.
460    #[test]
461    fn ordering_respects_agent_dependency_tiers() {
462        let idx = |s: Section| {
463            ONBOARDING_SECTIONS
464                .iter()
465                .position(|x| *x == s)
466                .unwrap_or_else(|| panic!("{s:?} missing from ONBOARDING_SECTIONS"))
467        };
468
469        // Brain + behavior shape + bundles + channels all precede Agents.
470        for upstream in [
471            Section::ModelProviders,
472            Section::RiskProfiles,
473            Section::RuntimeProfiles,
474            Section::SkillBundles,
475            Section::McpBundles,
476            Section::KnowledgeBundles,
477            Section::Channels,
478        ] {
479            assert!(
480                idx(upstream) < idx(Section::Agents),
481                "{upstream:?} must precede Agents (Agent references it through an alias field)",
482            );
483        }
484
485        // Storage precedes Memory (memory.backend = "<storage_type>.<alias>").
486        assert!(
487            idx(Section::Storage) < idx(Section::Memory),
488            "Storage must precede Memory (memory.backend points at a storage instance)",
489        );
490
491        // Topology references agents.
492        for downstream in [Section::PeerGroups, Section::Cron] {
493            assert!(
494                idx(Section::Agents) < idx(downstream),
495                "{downstream:?} references agents and must follow Agents in the canonical order",
496            );
497        }
498    }
499
500    /// Storage help must steer first-time operators toward SQLite as the
501    /// safe default. Pins the contract: SQLite is named, flagged as a
502    /// default/safe/recommended choice, and positioned before the
503    /// alternatives so the recommendation lands first instead of being
504    /// buried in a closing list.
505    #[test]
506    fn storage_help_steers_to_sqlite_default() {
507        let help = section_help("storage").to_lowercase();
508        let sqlite_pos = help
509            .find("sqlite")
510            .expect("storage help must mention SQLite by name");
511        assert!(
512            help.contains("default") || help.contains("safe") || help.contains("recommend"),
513            "storage help must signal SQLite is the default/safe/recommended choice; got: {help}",
514        );
515        for other in ["postgres", "qdrant", "markdown", "lucid"] {
516            let other_pos = help.find(other).unwrap_or_else(|| {
517                panic!(
518                    "storage help must still name `{other}` so operators know the alternatives \
519                     exist; got: {help}",
520                )
521            });
522            assert!(
523                sqlite_pos < other_pos,
524                "SQLite (at {sqlite_pos}) must be mentioned before `{other}` (at {other_pos}) so \
525                 the default recommendation lands first",
526            );
527        }
528    }
529}