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}