1use std::time::Duration;
11
12use anyhow::{Context, Result};
13use serde::Deserialize;
14use zeroclaw_config::schema::Config;
15use zeroclaw_config::traits::{Answer, OnboardUi, PropKind, SelectItem};
16
17use crate::agent::personality::EDITABLE_PERSONALITY_FILES;
18use crate::agent::personality_templates::{TemplateContext, render as render_personality};
19use crate::i18n;
20
21const CUSTOM_OPENAI_COMPAT_LABEL: &str = "Custom OpenAI-compatible endpoint";
22const OPENAI_COMPAT_MODELS_TIMEOUT: Duration = Duration::from_secs(10);
23
24macro_rules! acknowledge_only_sections {
28 () => {
29 Section::Storage
30 | Section::Cron
31 | Section::Mcp
32 | Section::McpBundles
33 | Section::KnowledgeBundles
34 };
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41enum Nav {
42 Done,
43 Back,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50enum SkipNav {
51 Skip,
52 Enter,
53 Back,
54}
55
56pub mod field_visibility;
57pub mod ui;
58
59#[derive(Debug, Deserialize)]
60struct OpenAiModelsResponse {
61 data: Vec<OpenAiModel>,
62}
63
64#[derive(Debug, Deserialize)]
65struct OpenAiModel {
66 id: String,
67}
68
69pub use zeroclaw_config::sections::Section;
70
71pub type Target = Option<Section>;
77
78#[must_use]
82pub fn section_for_path(path: &str) -> Option<Section> {
83 Section::from_key(path.split('.').next()?)
84}
85
86#[derive(Debug, Default, Clone)]
90pub struct Flags {
91 pub force: bool,
93 pub reinit: bool,
95 pub api_key: Option<String>,
96 pub model_provider: Option<String>,
97 pub model: Option<String>,
98 pub memory: Option<String>,
99}
100
101pub async fn run(
105 cfg: &mut Config,
106 ui: &mut dyn OnboardUi,
107 target: Target,
108 flags: &Flags,
109) -> Result<()> {
110 let Some(section) = target else {
111 return run_all(cfg, ui, flags).await;
112 };
113 let _ = dispatch_section(cfg, ui, flags, section).await?;
114 Ok(())
115}
116
117async fn dispatch_section(
124 cfg: &mut Config,
125 ui: &mut dyn OnboardUi,
126 flags: &Flags,
127 section: Section,
128) -> Result<Nav> {
129 match section {
135 Section::ModelProviders => Box::pin(model_providers(cfg, ui, flags)).await,
136 Section::TtsProviders | Section::TranscriptionProviders => {
137 Box::pin(no_wizard_acknowledge(
138 ui,
139 section,
140 &format!(
141 "No interactive wizard yet. Configure via the dashboard at \
142 /config/{section} or \
143 `zeroclaw config set {section}.<type>.<alias>.<field> <value>`."
144 ),
145 ))
146 .await
147 }
148 Section::Channels => Box::pin(channels(cfg, ui, flags)).await,
149 Section::Memory => Box::pin(memory(cfg, ui, flags)).await,
150 Section::Hardware => Box::pin(hardware(cfg, ui, flags)).await,
151 Section::Tunnel => Box::pin(tunnel(cfg, ui, flags)).await,
152 Section::Agents => Box::pin(agents(cfg, ui, flags)).await,
153 Section::Skills => Box::pin(skills(cfg, ui, flags)).await,
154 Section::SkillBundles => {
155 Box::pin(one_tier_alias_section(
156 cfg,
157 ui,
158 section,
159 "skill-bundles",
160 "Skill bundle",
161 ))
162 .await
163 }
164 Section::RiskProfiles => {
165 Box::pin(one_tier_alias_section(
166 cfg,
167 ui,
168 section,
169 "risk-profiles",
170 "Risk profile",
171 ))
172 .await
173 }
174 Section::RuntimeProfiles => {
175 Box::pin(one_tier_alias_section(
176 cfg,
177 ui,
178 section,
179 "runtime-profiles",
180 "Runtime profile",
181 ))
182 .await
183 }
184 Section::PeerGroups => {
185 Box::pin(one_tier_alias_section(
186 cfg,
187 ui,
188 section,
189 "peer-groups",
190 "Peer group",
191 ))
192 .await
193 }
194 acknowledge_only_sections!() => {
195 Box::pin(no_wizard_acknowledge(
196 ui,
197 section,
198 &format!(
199 "Configured via the dashboard at /config/{section} or \
200 `zeroclaw config set {section}.<alias>.<field> <value>` \
201 (not part of the initial wizard)."
202 ),
203 ))
204 .await
205 }
206 }
207}
208
209async fn no_wizard_acknowledge(
213 ui: &mut dyn OnboardUi,
214 section: Section,
215 explanation: &str,
216) -> Result<Nav> {
217 let mut label = section.as_str().replace(['_', '-', '.'], " ");
218 if let Some(c) = label.get_mut(0..1) {
219 c.make_ascii_uppercase();
220 }
221 ui.heading(1, &label);
222 let canonical = section.help();
223 let note = if canonical.is_empty() {
224 explanation.to_string()
225 } else {
226 format!("{canonical}\n\n{explanation}")
227 };
228 ui.note(¬e);
229 let _ = ui.confirm("Continue", false).await?;
230 Ok(Nav::Done)
231}
232
233async fn run_all(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<()> {
238 let order: Vec<Section> = zeroclaw_config::sections::ONBOARDING_SECTIONS.to_vec();
239 let mut i: usize = 0;
240 loop {
241 let Some(section) = order.get(i).copied() else {
242 return Ok(());
243 };
244 match dispatch_section(cfg, ui, flags, section).await? {
245 Nav::Done => i += 1,
246 Nav::Back => {
247 if i == 0 {
248 return Ok(());
249 }
250 i -= 1;
251 }
252 }
253 }
254}
255
256async fn persist(cfg: &mut Config, path: &str, value: &str) -> Result<()> {
261 cfg.set_prop_persistent(path, value)?;
262 cfg.save_dirty().await?;
263 Ok(())
264}
265
266fn emit_section_header(ui: &mut dyn OnboardUi, section: Section, display_label: &str) {
272 ui.heading(1, display_label);
273 let help = section.help();
274 if !help.is_empty() {
275 ui.note(help);
276 }
277}
278
279#[derive(Debug, Clone)]
288pub struct FieldDefault {
289 pub path: String,
290 pub display: String,
291}
292
293fn find_default<'a>(defaults: &'a [FieldDefault], path: &str) -> Option<&'a str> {
294 defaults
295 .iter()
296 .find(|d| d.path == path)
297 .map(|d| d.display.as_str())
298}
299
300fn pretty_print_object(value: &str) -> Option<String> {
305 let parsed: serde_json::Value = serde_json::from_str(value.trim()).ok()?;
306 serde_json::to_string_pretty(&parsed).ok()
307}
308
309fn compact_object(edited: &str) -> Option<String> {
313 let parsed: serde_json::Value = serde_json::from_str(edited.trim()).ok()?;
314 serde_json::to_string(&parsed).ok()
315}
316
317fn alias_options_for_type_hint(cfg: &Config, type_hint: &str) -> Option<Vec<String>> {
325 let dotted = |prefix: &str| -> Vec<String> {
326 let mut out: Vec<String> = Vec::new();
327 for f in cfg.prop_fields() {
328 if let Some(rest) = f.name.strip_prefix(&format!("{prefix}.")) {
329 let mut parts = rest.splitn(3, '.');
330 if let (Some(ty), Some(alias), Some(_)) = (parts.next(), parts.next(), parts.next())
331 {
332 let dotted = format!("{ty}.{alias}");
333 if !out.contains(&dotted) {
334 out.push(dotted);
335 }
336 }
337 }
338 }
339 out.sort();
340 out
341 };
342 let bare = |section: &str| -> Vec<String> { cfg.get_map_keys(section).unwrap_or_default() };
343 if type_hint.contains("ChannelRef") {
344 Some(dotted("channels"))
345 } else if type_hint.contains("ModelProviderRef") {
346 Some(dotted("providers.models"))
347 } else if type_hint.contains("TtsProviderRef") {
348 Some(dotted("providers.tts"))
349 } else if type_hint.contains("TranscriptionProviderRef") {
350 Some(dotted("providers.transcription"))
351 } else if type_hint.contains("AgentAlias") {
352 Some(bare("agents"))
353 } else {
354 None
355 }
356}
357
358fn parses_as_string_array(input: &str) -> bool {
359 toml::from_str::<std::collections::HashMap<String, Vec<String>>>(&format!("v = {input}"))
360 .is_ok()
361}
362
363async fn prompt_field(
369 cfg: &mut Config,
370 ui: &mut dyn OnboardUi,
371 name: &str,
372 default: Option<&str>,
373) -> Result<Nav> {
374 if cfg.prop_is_env_overridden(name) {
378 let env_var = format!("ZEROCLAW_{}", name.replace('.', "__").replace('-', "_"),);
379 ui.note(&format!(
380 "\u{1f489} {name}\n\
381 overridden by env: {env_var}\n\
382 config.toml path: [{name}] — skipping prompt, value sourced from environment.",
383 ));
384 return Ok(Nav::Done);
385 }
386
387 let field = cfg
388 .prop_fields()
389 .into_iter()
390 .find(|f| f.name == name)
391 .ok_or_else(|| {
392 ::zeroclaw_log::record!(
393 WARN,
394 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
395 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
396 .with_attrs(::serde_json::json!({"name": name})),
397 "onboard: unknown config field"
398 );
399 anyhow::Error::msg(format!("unknown config field: {name}"))
400 })?;
401
402 let short = name.rsplit('.').next().unwrap_or(name);
403 let current = field.display_value;
404 let is_set = field.kind != PropKind::Bool && !current.is_empty() && current != "<unset>";
412
413 let mut help = field.description.to_string();
418 if field.kind == PropKind::StringArray {
423 if !help.is_empty() {
424 help.push('\n');
425 }
426 help.push_str("Format: alice,bob or [\"alice\", \"bob\"]. Empty = clear list.");
427 }
428 if !is_set
429 && let Some(d) = default
430 && !d.is_empty()
431 {
432 if !help.is_empty() {
433 help.push('\n');
434 }
435 help.push_str(&format!("Default: {d}. Press Enter to accept."));
436 } else if is_set {
437 if !help.is_empty() {
438 help.push('\n');
439 }
440 help.push_str(&format!("Current: {current}. Enter to keep."));
441 }
442 ui.note(&help);
443
444 let prompt = short;
445
446 if field.is_secret {
447 match ui.secret(prompt, is_set).await? {
448 Answer::Back => return Ok(Nav::Back),
449 Answer::Value(Some(value)) => persist(cfg, name, &value).await?,
450 Answer::Value(None) => {}
451 }
452 return Ok(Nav::Done);
453 }
454
455 match field.kind {
456 PropKind::Bool => {
457 let cur = current.parse::<bool>().unwrap_or(false);
458 match ui.confirm(prompt, cur).await? {
459 Answer::Back => return Ok(Nav::Back),
460 Answer::Value(new) if new != cur => persist(cfg, name, &new.to_string()).await?,
461 Answer::Value(_) => {}
462 }
463 }
464 PropKind::String | PropKind::Integer | PropKind::Float => {
465 if let Some(options) = alias_options_for_type_hint(cfg, field.type_hint) {
471 let items: Vec<SelectItem> = options.iter().map(SelectItem::new).collect();
472 let current_idx = if is_set {
473 options.iter().position(|v| v == ¤t)
474 } else {
475 default.and_then(|d| options.iter().position(|v| v == d))
476 };
477 match ui.select(prompt, &items, current_idx).await? {
478 Answer::Back => return Ok(Nav::Back),
479 Answer::Value(idx) => {
480 let new = options[idx].clone();
481 if (is_set || !new.is_empty()) && new != current {
482 persist(cfg, name, &new).await?;
483 }
484 }
485 }
486 return Ok(Nav::Done);
487 }
488 let (prefill, placeholder) = if is_set {
492 (Some(current.as_str()), None)
493 } else {
494 (None, default)
495 };
496 match ui.string(prompt, prefill, placeholder).await? {
497 Answer::Back => return Ok(Nav::Back),
498 Answer::Value(new) => {
499 if (is_set || !new.is_empty()) && new != current {
500 persist(cfg, name, &new).await?;
501 }
502 }
503 }
504 }
505 PropKind::StringArray => {
506 let (prefill, placeholder) = if is_set {
507 (Some(current.as_str()), None)
508 } else {
509 (None, default)
510 };
511 loop {
516 match ui.string(prompt, prefill, placeholder).await? {
517 Answer::Back => return Ok(Nav::Back),
518 Answer::Value(new) => {
519 let trimmed = new.trim();
520 if trimmed.starts_with('[') && !parses_as_string_array(trimmed) {
521 ui.note("Invalid array. Use alice,bob or [\"alice\", \"bob\"].");
522 continue;
523 }
524 if (is_set || !new.is_empty()) && new != current {
525 persist(cfg, name, &new).await?;
526 }
527 ui.note("");
528 break;
529 }
530 }
531 }
532 }
533 PropKind::Enum => {
534 let variants = field.enum_variants.map(|get| get()).unwrap_or_default();
535 if variants.is_empty() {
536 ui.warn(&format!("skipping {name}: no enum variants exposed"));
537 return Ok(Nav::Done);
538 }
539 let items: Vec<SelectItem> = variants.iter().map(SelectItem::new).collect();
540 let current_idx = if is_set {
541 variants.iter().position(|v| v == ¤t)
542 } else {
543 default.and_then(|d| variants.iter().position(|v| v == d))
544 };
545 match ui.select(prompt, &items, current_idx).await? {
546 Answer::Back => return Ok(Nav::Back),
547 Answer::Value(idx) => {
548 let new = &variants[idx];
549 if new != ¤t {
550 persist(cfg, name, new).await?;
551 }
552 }
553 }
554 }
555 PropKind::ObjectArray => {
556 let (prefill, placeholder) = if is_set {
561 (Some(current.as_str()), None)
562 } else {
563 (None, default)
564 };
565 match ui.string(prompt, prefill, placeholder).await? {
566 Answer::Back => return Ok(Nav::Back),
567 Answer::Value(new) => {
568 if (is_set || !new.is_empty()) && new != current {
569 persist(cfg, name, &new).await?;
570 }
571 }
572 }
573 }
574 PropKind::Object => {
575 let initial = if is_set {
584 pretty_print_object(¤t).unwrap_or_else(|| current.clone())
585 } else {
586 default.map(str::to_string).unwrap_or_default()
587 };
588 let hint = format!(
589 "Editing {name}. Save and exit to apply, or quit without saving to keep the current value."
590 );
591 match ui.editor(&hint, &initial).await? {
592 Answer::Back => return Ok(Nav::Back),
593 Answer::Value(new) => {
594 let normalized = compact_object(&new).unwrap_or_else(|| new.trim().to_string());
595 if (is_set || !normalized.is_empty()) && normalized != current {
596 persist(cfg, name, &normalized).await?;
597 }
598 }
599 }
600 }
601 }
602 Ok(Nav::Done)
603}
604
605async fn prompt_fields_under(
611 cfg: &mut Config,
612 ui: &mut dyn OnboardUi,
613 prefix: &str,
614 excludes: &[&str],
615 defaults: &[FieldDefault],
616) -> Result<Nav> {
617 let names: Vec<String> = cfg
618 .prop_fields()
619 .into_iter()
620 .filter_map(|f| {
621 let suffix = f.name.strip_prefix(prefix)?.strip_prefix('.')?;
622 if suffix.contains('.') || excludes.contains(&suffix) {
623 return None;
624 }
625 Some(f.name.to_string())
626 })
627 .collect();
628 let mut i: usize = 0;
629 while i < names.len() {
630 let default = find_default(defaults, &names[i]);
631 match prompt_field(cfg, ui, &names[i], default).await? {
632 Nav::Done => i += 1,
633 Nav::Back => {
634 if i == 0 {
635 return Ok(Nav::Back);
636 }
637 i -= 1;
638 }
639 }
640 }
641 Ok(Nav::Done)
642}
643
644async fn skip_if_configured(
650 cfg: &Config,
651 ui: &mut dyn OnboardUi,
652 flags: &Flags,
653 section: Section,
654 label: &str,
655 has_signal: bool,
656) -> Result<SkipNav> {
657 if flags.force {
658 return Ok(SkipNav::Enter);
659 }
660 let key = section.as_str();
661 let seen = cfg
662 .onboard_state
663 .completed_sections
664 .iter()
665 .any(|s| s == key);
666 if !seen && !has_signal {
667 return Ok(SkipNav::Enter);
668 }
669 match ui
670 .confirm(
671 &format!("{label} is already configured. Reconfigure?"),
672 false,
673 )
674 .await?
675 {
676 Answer::Back => Ok(SkipNav::Back),
677 Answer::Value(true) => Ok(SkipNav::Enter),
678 Answer::Value(false) => Ok(SkipNav::Skip),
679 }
680}
681
682fn section_has_signal(cfg: &Config, section: Section) -> bool {
688 match section {
689 Section::ModelProviders => !cfg.providers.models.is_empty(),
690 Section::Channels => cfg.prop_fields().iter().any(|f| {
696 f.name
697 .strip_prefix("channels.")
698 .is_some_and(|rest| rest.contains('.'))
699 }),
700 Section::Hardware => cfg.hardware.enabled,
701 Section::TtsProviders
707 | Section::TranscriptionProviders
708 | Section::Memory
709 | Section::Tunnel
710 | Section::Agents
711 | Section::Skills
712 | Section::SkillBundles
713 | Section::RiskProfiles
714 | Section::RuntimeProfiles
715 | Section::PeerGroups => false,
716 acknowledge_only_sections!() => false,
717 }
718}
719
720fn is_known_model_provider_name(model_provider: &str) -> bool {
721 let model_provider = model_provider.trim();
722 zeroclaw_providers::list_model_providers()
723 .iter()
724 .any(|entry| entry.name.eq_ignore_ascii_case(model_provider))
725}
726
727fn openai_compat_models_endpoint(base_url: &str) -> Result<reqwest::Url> {
728 let raw = base_url.trim();
729 if raw.is_empty() {
730 anyhow::bail!("OpenAI-compatible model discovery requires a base URL");
731 }
732
733 let mut endpoint = reqwest::Url::parse(raw)
734 .with_context(|| format!("OpenAI-compatible base URL is invalid: {raw}"))?;
735 if !matches!(endpoint.scheme(), "http" | "https") {
736 anyhow::bail!("OpenAI-compatible base URL must use http:// or https://");
737 }
738
739 let path = endpoint.path().trim_end_matches('/');
740 if path.ends_with("/models") {
741 endpoint.set_query(None);
742 endpoint.set_fragment(None);
743 return Ok(endpoint);
744 }
745
746 let suffix = if path.ends_with("/v1") || path.contains("/v1/") {
747 "models"
748 } else {
749 "v1/models"
750 };
751 let next_path = if path.is_empty() {
752 format!("/{suffix}")
753 } else {
754 format!("{path}/{suffix}")
755 };
756 endpoint.set_path(&next_path);
757 endpoint.set_query(None);
758 endpoint.set_fragment(None);
759 Ok(endpoint)
760}
761
762async fn discover_openai_compat_models(
763 base_url: &str,
764 api_key: Option<&str>,
765) -> Result<Vec<String>> {
766 discover_openai_compat_models_with_timeout(base_url, api_key, OPENAI_COMPAT_MODELS_TIMEOUT)
767 .await
768}
769
770async fn discover_openai_compat_models_with_timeout(
771 base_url: &str,
772 api_key: Option<&str>,
773 timeout: Duration,
774) -> Result<Vec<String>> {
775 let endpoint = openai_compat_models_endpoint(base_url)?;
776 let client = reqwest::Client::builder()
777 .timeout(timeout)
778 .build()
779 .context("failed to build OpenAI-compatible discovery client")?;
780
781 let mut request = client.get(endpoint.clone());
782 if let Some(key) = api_key.map(str::trim).filter(|key| !key.is_empty()) {
783 request = request.bearer_auth(key);
784 }
785
786 let response = request
787 .send()
788 .await
789 .with_context(|| format!("OpenAI-compatible model discovery request failed: {endpoint}"))?;
790 let status = response.status();
791 if !status.is_success() {
792 anyhow::bail!("OpenAI-compatible model discovery failed at {endpoint}: HTTP {status}");
793 }
794
795 let payload: OpenAiModelsResponse = response.json().await.with_context(|| {
796 format!("OpenAI-compatible model discovery returned invalid JSON: {endpoint}")
797 })?;
798 let models: Vec<String> = payload
799 .data
800 .into_iter()
801 .map(|model| model.id.trim().to_string())
802 .filter(|id| !id.is_empty())
803 .collect();
804 if models.is_empty() {
805 anyhow::bail!("OpenAI-compatible model discovery returned no model ids: {endpoint}");
806 }
807 Ok(models)
808}
809
810fn openai_compat_discovery_base_url(
811 model_provider: &str,
812 configured_base_url: Option<&str>,
813) -> Option<String> {
814 configured_base_url
815 .map(str::trim)
816 .filter(|url| !url.is_empty())
817 .map(ToString::to_string)
818 .or_else(|| {
819 model_provider
820 .trim()
821 .strip_prefix("custom:")
822 .map(str::trim)
823 .filter(|url| !url.is_empty())
824 .map(ToString::to_string)
825 })
826}
827
828async fn prompt_custom_openai_base_url(ui: &mut dyn OnboardUi) -> Result<Option<String>> {
829 loop {
830 match ui.string("OpenAI-compatible base URL", None, None).await? {
831 Answer::Back => return Ok(None),
832 Answer::Value(value) => {
833 let normalized = value.trim().trim_end_matches('/').to_string();
834 if openai_compat_models_endpoint(&normalized).is_ok() {
835 return Ok(Some(normalized));
836 }
837 ui.note("Enter an http:// or https:// URL for an OpenAI-compatible API base.");
838 }
839 }
840 }
841}
842
843async fn prompt_alias_name(ui: &mut dyn OnboardUi, suggestion: &str) -> Result<Option<String>> {
847 loop {
848 match ui
852 .string(
853 "Alias (name for this configuration)",
854 None,
855 Some(suggestion),
856 )
857 .await?
858 {
859 Answer::Back => return Ok(None),
860 Answer::Value(s) => {
861 let trimmed = if s.trim().is_empty() {
862 suggestion.to_string()
863 } else {
864 s.trim().to_string()
865 };
866 match zeroclaw_config::helpers::validate_alias_key(&trimmed) {
867 Ok(()) => return Ok(Some(trimmed)),
868 Err(msg) => ui.warn(&format!("Invalid alias: {msg}")),
869 }
870 }
871 }
872 }
873}
874
875async fn mark_completed(cfg: &mut Config, section: Section) -> Result<()> {
876 let key = section.as_str();
877 if cfg
878 .onboard_state
879 .completed_sections
880 .iter()
881 .any(|s| s == key)
882 {
883 return Ok(());
884 }
885 cfg.onboard_state.completed_sections.push(key.to_string());
886 cfg.mark_dirty("onboard-state.completed-sections");
887 cfg.save_dirty().await?;
888 Ok(())
889}
890
891async fn model_providers(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
897 emit_section_header(ui, Section::ModelProviders, "Providers");
898
899 let entries = zeroclaw_providers::list_model_providers();
902
903 loop {
904 let current_type = cfg.first_model_provider_type().unwrap_or("").to_string();
905
906 let (picked, selected_base_url) = match &flags.model_provider {
907 Some(forced) => (forced.clone(), None),
908 None => {
909 let current_idx = entries.iter().position(|p| p.name == current_type);
910 let mut options: Vec<SelectItem> = entries
911 .iter()
912 .map(|p| {
913 let configured = cfg.providers.models.contains_model_provider_type(p.name);
914 let badge = configured.then(|| "[configured]".into());
921 SelectItem {
922 label: p.display_name.to_string(),
923 badge,
924 }
925 })
926 .collect();
927 let custom_idx = options.len();
928 options.push(SelectItem::new(CUSTOM_OPENAI_COMPAT_LABEL));
929 let done_idx = options.len();
933 options.push(SelectItem::new("Done"));
934 let initial = current_idx.or(Some(done_idx));
935 let idx = match ui.select("ModelProvider", &options, initial).await? {
936 Answer::Back => return Ok(Nav::Back),
937 Answer::Value(idx) => idx,
938 };
939 if idx == done_idx {
940 break;
941 }
942 if idx == custom_idx {
943 let Some(base_url) = prompt_custom_openai_base_url(ui).await? else {
944 continue;
945 };
946 ("custom".to_string(), Some(base_url))
947 } else {
948 (entries[idx].name.to_string(), None)
949 }
950 }
951 };
952
953 let display_name = entries
959 .iter()
960 .find(|p| p.name == picked)
961 .map(|p| p.display_name)
962 .unwrap_or_else(|| {
963 if picked == "custom" {
964 CUSTOM_OPENAI_COMPAT_LABEL
965 } else {
966 picked.as_str()
967 }
968 });
969 ui.heading(2, display_name);
970
971 let alias = if flags.model_provider.is_some() {
974 "default".to_string()
975 } else {
976 let existing_aliases: Vec<String> = cfg
977 .get_map_keys(&format!("providers.models.{picked}"))
978 .unwrap_or_default();
979 if existing_aliases.is_empty() {
980 ui.note(&format!(
983 "Short identifier for this {display_name} configuration. \
984 Letters, digits, underscores. Empty = use the suggested default."
985 ));
986 let Some(a) = prompt_alias_name(ui, "default").await? else {
987 continue;
988 };
989 a
990 } else {
991 let mut alias_options: Vec<SelectItem> = existing_aliases
992 .iter()
993 .map(|a| SelectItem::new(a.clone()))
994 .collect();
995 let add_new_idx = alias_options.len();
996 alias_options.push(SelectItem::new("+ Add new"));
997 let alias_idx = match ui.select("Alias", &alias_options, Some(0)).await? {
998 Answer::Back => continue,
999 Answer::Value(i) => i,
1000 };
1001 if alias_idx == add_new_idx {
1002 ui.note(&format!(
1003 "Short identifier for this {display_name} configuration. \
1004 Letters, digits, underscores. Empty = use the suggested default."
1005 ));
1006 let suggestion = format!("{}-2", existing_aliases[0]);
1007 let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1008 continue;
1009 };
1010 a
1011 } else {
1012 existing_aliases[alias_idx].clone()
1013 }
1014 }
1015 };
1016
1017 let is_new_entry = cfg.providers.models.find(&picked, &alias).is_none();
1023 cfg.providers.models.ensure(&picked, &alias);
1024
1025 if let Some(base_url) = selected_base_url.as_deref() {
1033 cfg.set_prop_persistent(&format!("providers.models.{picked}.{alias}.uri"), base_url)?;
1034 }
1035
1036 let prefix = format!("providers.models.{picked}.{alias}");
1043 let api_key_path = format!("{prefix}.api-key");
1044 if let Some(api_key) = &flags.api_key {
1045 persist(cfg, &api_key_path, api_key).await?;
1046 if picked == "openai" {
1051 persist(cfg, &format!("{prefix}.requires-openai-auth"), "false").await?;
1052 }
1053 }
1054 if let Some(model) = &flags.model {
1055 persist(cfg, &format!("{prefix}.model"), model).await?;
1056 }
1057
1058 if flags.api_key.is_none() {
1063 ui.heading(2, &format!("{display_name} › Authentication"));
1064
1065 if picked == "openai" {
1069 let currently_codex = cfg
1070 .providers
1071 .models
1072 .find("openai", &alias)
1073 .map(|c| c.requires_openai_auth)
1074 .unwrap_or(false);
1075 ui.note(&i18n::get_required_cli_string("onboard-openai-auth-note"));
1076 let auth_prompt = i18n::get_required_cli_string("onboard-openai-auth-prompt");
1077 let auth_items = [
1078 SelectItem::new(i18n::get_required_cli_string("onboard-openai-auth-api-key")),
1079 SelectItem::new(i18n::get_required_cli_string("onboard-openai-auth-codex")),
1080 ];
1081 let auth_default = if currently_codex { Some(1) } else { Some(0) };
1082 let codex_chosen = match ui.select(&auth_prompt, &auth_items, auth_default).await? {
1083 Answer::Back => {
1084 if flags.model_provider.is_some() {
1085 return Ok(Nav::Back);
1086 }
1087 if is_new_entry {
1088 cfg.providers.models.remove_alias(&picked, &alias);
1089 cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1090 }
1091 continue;
1092 }
1093 Answer::Value(1) => true,
1094 Answer::Value(_) => false,
1095 };
1096 if codex_chosen {
1097 persist(cfg, &format!("{prefix}.requires-openai-auth"), "true").await?;
1098 persist(cfg, &format!("{prefix}.wire-api"), "responses").await?;
1099 ui.note(&i18n::get_required_cli_string(
1100 "onboard-openai-codex-followup",
1101 ));
1102 } else {
1103 if currently_codex {
1104 persist(cfg, &format!("{prefix}.requires-openai-auth"), "false").await?;
1105 }
1106 match prompt_field(cfg, ui, &api_key_path, None).await? {
1107 Nav::Back => {
1108 if flags.model_provider.is_some() {
1109 return Ok(Nav::Back);
1110 }
1111 if is_new_entry {
1112 cfg.providers.models.remove_alias(&picked, &alias);
1113 cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1114 }
1115 continue;
1116 }
1117 Nav::Done => {}
1118 }
1119 }
1120 } else {
1121 match prompt_field(cfg, ui, &api_key_path, None).await? {
1122 Nav::Back => {
1123 if flags.model_provider.is_some() {
1124 return Ok(Nav::Back);
1125 }
1126 if is_new_entry {
1127 cfg.providers.models.remove_alias(&picked, &alias);
1128 cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1129 }
1130 continue;
1131 }
1132 Nav::Done => {}
1133 }
1134 }
1135 ui.heading(2, display_name);
1136 }
1137
1138 if flags.model.is_none() {
1139 ui.heading(2, &format!("{display_name} › Model"));
1140 match prompt_model(cfg, ui, &prefix).await? {
1141 Nav::Back => {
1142 if flags.model_provider.is_some() {
1143 return Ok(Nav::Back);
1144 }
1145 if is_new_entry {
1146 cfg.providers.models.remove_alias(&picked, &alias);
1147 cfg.mark_dirty(&format!("providers.models.{picked}.{alias}"));
1148 }
1149 continue;
1150 }
1151 Nav::Done => {}
1152 }
1153 ui.heading(2, display_name);
1154 }
1155
1156 match offer_advanced_settings(cfg, ui, &prefix).await? {
1160 Nav::Back => {
1161 if flags.model_provider.is_some() {
1162 return Ok(Nav::Back);
1163 }
1164 continue;
1165 }
1166 Nav::Done => {}
1167 }
1168
1169 break;
1170 }
1171
1172 mark_completed(cfg, Section::ModelProviders).await?;
1173 Ok(Nav::Done)
1174}
1175
1176async fn offer_advanced_settings(
1181 cfg: &mut Config,
1182 ui: &mut dyn OnboardUi,
1183 prefix: &str,
1184) -> Result<Nav> {
1185 ui.heading(2, "Advanced settings");
1186 ui.note(
1187 "Temperature, timeout, base-URL override, wire protocol, etc. The \
1188 model_provider's own defaults are used when these are left unset — skip \
1189 unless you need to override something specific.",
1190 );
1191 match ui.confirm("Configure advanced settings?", false).await? {
1192 Answer::Back => return Ok(Nav::Back),
1193 Answer::Value(false) => return Ok(Nav::Done),
1194 Answer::Value(true) => {}
1195 }
1196
1197 let excludes: Vec<&str> = vec!["model", "api-key"];
1203
1204 let mut defaults: Vec<FieldDefault> = Vec::new();
1211 if let Some((type_k, alias_k)) = prefix
1212 .strip_prefix("providers.models.")
1213 .and_then(|rest| rest.split_once('.'))
1214 {
1215 let uri = cfg
1218 .providers
1219 .models
1220 .resolved_endpoint_uri(type_k, alias_k)
1221 .map(str::to_string)
1222 .or_else(|| zeroclaw_providers::default_model_provider_url(type_k).map(str::to_string));
1223 if let Some(uri) = uri {
1224 defaults.push(FieldDefault {
1225 path: format!("{prefix}.uri"),
1226 display: uri,
1227 });
1228 }
1229 }
1230 defaults.push(FieldDefault {
1231 path: format!("{prefix}.temperature"),
1232 display: "0.7".to_string(),
1233 });
1234 defaults.push(FieldDefault {
1235 path: format!("{prefix}.timeout-secs"),
1236 display: "120".to_string(),
1237 });
1238
1239 prompt_fields_under(cfg, ui, prefix, &excludes, &defaults).await
1240}
1241
1242async fn prompt_model(cfg: &mut Config, ui: &mut dyn OnboardUi, prefix: &str) -> Result<Nav> {
1249 let model_path = format!("{prefix}.model");
1250 let current = cfg.get_prop(&model_path).unwrap_or_default();
1251 let is_set = !current.is_empty() && current != "<unset>";
1252 let (model_provider, profile) = match prefix.strip_prefix("providers.models.") {
1254 Some(rest) => {
1255 if let Some((type_k, alias_k)) = rest.split_once('.') {
1256 let profile = cfg.providers.models.find(type_k, alias_k);
1257 (type_k.to_string(), profile)
1258 } else {
1259 (rest.to_string(), None)
1260 }
1261 }
1262 None => (prefix.to_string(), None),
1263 };
1264 let api_key = profile.and_then(|entry| entry.api_key.as_deref());
1265 let configured_uri = profile.and_then(|entry| entry.uri.as_deref());
1266 let discovery_base_url = openai_compat_discovery_base_url(&model_provider, configured_uri);
1267 let should_try_openai_compat =
1268 model_provider.trim() == "custom" || !is_known_model_provider_name(&model_provider);
1269
1270 let catalog_models = match zeroclaw_providers::create_model_provider(&model_provider, None) {
1271 Ok(handle) => {
1272 ui.status("Fetching models...");
1273 match handle.list_models().await {
1274 Ok(models) => Some(models),
1275 Err(e) => {
1276 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "error": format!("{}", e)})), "models.dev catalog fetch failed");
1277 None
1278 }
1279 }
1280 }
1281 Err(e) => {
1282 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "error": format!("{}", e)})), "model_provider construction failed for model-list probe");
1283 None
1284 }
1285 };
1286 let live_models = match catalog_models.filter(|ms| !ms.is_empty()) {
1287 Some(models) => Some(models),
1288 None if should_try_openai_compat => {
1289 if let Some(base_url) = discovery_base_url.as_deref() {
1290 ui.status("Fetching models from /v1/models...");
1291 match discover_openai_compat_models(base_url, api_key).await {
1292 Ok(models) => Some(models),
1293 Err(e) => {
1294 ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"model_provider": model_provider, "base_url": base_url, "error": format!("{}", e)})), "OpenAI-compatible model discovery failed");
1295 None
1296 }
1297 }
1298 } else {
1299 None
1300 }
1301 }
1302 None => None,
1303 };
1304 let live_models = match live_models {
1309 Some(ms) => Some(ms),
1310 None => match zeroclaw_providers::catalog::list_models_for_family(&model_provider).await {
1311 Ok(ms) if !ms.is_empty() => {
1312 ui.status("");
1313 Some(ms)
1314 }
1315 Ok(_) | Err(_) => None,
1316 },
1317 };
1318 ui.status("");
1322
1323 let new_value = match live_models {
1324 Some(models) => {
1325 let items: Vec<SelectItem> = models.iter().map(SelectItem::new).collect();
1326 let current_idx = models.iter().position(|m| m == ¤t);
1327 match ui.select("Model", &items, current_idx).await? {
1328 Answer::Back => return Ok(Nav::Back),
1329 Answer::Value(idx) => models[idx].clone(),
1330 }
1331 }
1332 None => {
1333 ui.note(&format!(
1338 "Catalog lookup failed for {model_provider} — enter a model id manually \
1339 (see the model_provider's docs for the exact format)."
1340 ));
1341 let prefill = if is_set { Some(current.as_str()) } else { None };
1342 match ui.string("Model id", prefill, None).await? {
1343 Answer::Back => return Ok(Nav::Back),
1344 Answer::Value(v) => v,
1345 }
1346 }
1347 };
1348
1349 if new_value != current && !new_value.is_empty() {
1350 persist(cfg, &model_path, &new_value).await?;
1351 }
1352 Ok(Nav::Done)
1353}
1354
1355async fn channels(cfg: &mut Config, ui: &mut dyn OnboardUi, _flags: &Flags) -> Result<Nav> {
1356 emit_section_header(ui, Section::Channels, "Channels");
1357 loop {
1358 let all_channels: Vec<String> = {
1362 let prefix = "channels.";
1363 zeroclaw_config::schema::Config::map_key_sections()
1364 .into_iter()
1365 .filter_map(|s| {
1366 s.path
1367 .strip_prefix(prefix)
1368 .filter(|rest| !rest.contains('.'))
1369 .map(String::from)
1370 })
1371 .collect::<std::collections::BTreeSet<_>>()
1372 .into_iter()
1373 .collect()
1374 };
1375 let live_fields: Vec<String> = cfg.prop_fields().into_iter().map(|f| f.name).collect();
1377 let configured: std::collections::BTreeSet<String> = all_channels
1378 .iter()
1379 .filter(|name| {
1380 let prefix = format!("channels.{name}.");
1381 live_fields.iter().any(|f| f.starts_with(&prefix))
1382 })
1383 .cloned()
1384 .collect();
1385
1386 let mut options: Vec<SelectItem> = all_channels
1387 .iter()
1388 .map(|name| {
1389 let is_active = live_fields.iter().any(|f| {
1395 f.starts_with(&format!("channels.{name}."))
1396 && f.ends_with(".enabled")
1397 && cfg.get_prop(f).ok().as_deref() == Some("true")
1398 });
1399 if is_active {
1400 SelectItem::with_badge(name.clone(), "[active]")
1401 } else if configured.contains(name) {
1402 SelectItem::with_badge(name.clone(), "[configured]")
1403 } else {
1404 SelectItem::new(name.clone())
1405 }
1406 })
1407 .collect();
1408 let done_idx = options.len();
1409 options.push(SelectItem::new("Done"));
1410
1411 let idx = match ui.select("Channel", &options, Some(done_idx)).await? {
1412 Answer::Back => return Ok(Nav::Back),
1413 Answer::Value(i) => i,
1414 };
1415 if idx == done_idx {
1416 break;
1417 }
1418
1419 let picked = &all_channels[idx];
1420 let existing_aliases: Vec<String> = cfg
1422 .get_map_keys(&format!("channels.{picked}"))
1423 .unwrap_or_default();
1424 let alias = if existing_aliases.is_empty() {
1425 let Some(a) = prompt_alias_name(ui, "default").await? else {
1426 continue;
1427 };
1428 a
1429 } else {
1430 let mut alias_options: Vec<SelectItem> = existing_aliases
1431 .iter()
1432 .map(|a| SelectItem::new(a.clone()))
1433 .collect();
1434 let add_new_idx = alias_options.len();
1435 alias_options.push(SelectItem::new("+ Add new"));
1436 let alias_idx = match ui.select("Alias", &alias_options, Some(0)).await? {
1437 Answer::Back => continue,
1438 Answer::Value(i) => i,
1439 };
1440 if alias_idx == add_new_idx {
1441 let suggestion = format!("{}-2", existing_aliases[0]);
1442 let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1443 continue;
1444 };
1445 a
1446 } else {
1447 existing_aliases[alias_idx].clone()
1448 }
1449 };
1450 cfg.create_map_key(&format!("channels.{picked}"), &alias)
1451 .ok();
1452 let prefix = format!("channels.{picked}.{alias}");
1453 cfg.mark_dirty(&prefix);
1454 cfg.save_dirty().await?;
1455 ui.heading(2, picked);
1456 let _ = prompt_fields_under(cfg, ui, &prefix, &[], &[]).await?;
1459 }
1460 mark_completed(cfg, Section::Channels).await?;
1461 Ok(Nav::Done)
1462}
1463
1464async fn memory(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
1465 emit_section_header(ui, Section::Memory, "Memory");
1466 if flags.memory.is_none() {
1467 match skip_if_configured(
1468 cfg,
1469 ui,
1470 flags,
1471 Section::Memory,
1472 "Memory",
1473 section_has_signal(cfg, Section::Memory),
1474 )
1475 .await?
1476 {
1477 SkipNav::Skip => return Ok(Nav::Done),
1478 SkipNav::Back => return Ok(Nav::Back),
1479 SkipNav::Enter => {}
1480 }
1481 }
1482 let backends = zeroclaw_memory::selectable_memory_backends();
1483 let current_backend = cfg.memory.backend.clone();
1484 let new_backend = match &flags.memory {
1485 Some(forced) => forced.clone(),
1486 None => {
1487 let options: Vec<SelectItem> =
1488 backends.iter().map(|b| SelectItem::new(b.label)).collect();
1489 let current_idx = backends.iter().position(|b| b.key == current_backend);
1490 match ui.select("Memory backend", &options, current_idx).await? {
1491 Answer::Back => return Ok(Nav::Back),
1492 Answer::Value(idx) => backends[idx].key.to_string(),
1493 }
1494 }
1495 };
1496 if new_backend != current_backend {
1497 persist(cfg, "memory.backend", &new_backend).await?;
1498 }
1499
1500 let _ = prompt_field(cfg, ui, "memory.auto-save", None).await?;
1502 mark_completed(cfg, Section::Memory).await?;
1503 Ok(Nav::Done)
1504}
1505
1506async fn hardware(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
1507 emit_section_header(ui, Section::Hardware, "Hardware");
1508 match skip_if_configured(
1509 cfg,
1510 ui,
1511 flags,
1512 Section::Hardware,
1513 "Hardware",
1514 section_has_signal(cfg, Section::Hardware),
1515 )
1516 .await?
1517 {
1518 SkipNav::Skip => return Ok(Nav::Done),
1519 SkipNav::Back => return Ok(Nav::Back),
1520 SkipNav::Enter => {}
1521 }
1522
1523 loop {
1524 match prompt_field(cfg, ui, "hardware.enabled", None).await? {
1525 Nav::Back => return Ok(Nav::Back),
1526 Nav::Done => {}
1527 }
1528 if cfg.hardware.enabled {
1529 match prompt_fields_under(cfg, ui, "hardware", &["enabled"], &[]).await? {
1530 Nav::Back => continue,
1531 Nav::Done => break,
1532 }
1533 } else {
1534 break;
1535 }
1536 }
1537 mark_completed(cfg, Section::Hardware).await?;
1538 Ok(Nav::Done)
1539}
1540
1541async fn tunnel(cfg: &mut Config, ui: &mut dyn OnboardUi, flags: &Flags) -> Result<Nav> {
1542 emit_section_header(ui, Section::Tunnel, "Tunnel");
1543 match skip_if_configured(
1544 cfg,
1545 ui,
1546 flags,
1547 Section::Tunnel,
1548 "Tunnel",
1549 section_has_signal(cfg, Section::Tunnel),
1550 )
1551 .await?
1552 {
1553 SkipNav::Skip => return Ok(Nav::Done),
1554 SkipNav::Back => return Ok(Nav::Back),
1555 SkipNav::Enter => {}
1556 }
1557
1558 loop {
1559 let mut provider_names: Vec<String> = cfg
1563 .prop_fields()
1564 .iter()
1565 .filter_map(|f| f.name.strip_prefix("tunnel."))
1566 .filter_map(|suffix| suffix.split_once('.').map(|(head, _)| head.to_string()))
1567 .collect::<std::collections::BTreeSet<_>>()
1568 .into_iter()
1569 .collect();
1570 provider_names.insert(0, "none".to_string());
1571
1572 let options: Vec<SelectItem> = provider_names.iter().map(SelectItem::new).collect();
1573 let current_model_provider = cfg.tunnel.tunnel_provider.clone();
1574 let current_idx = provider_names
1575 .iter()
1576 .position(|p| p == ¤t_model_provider);
1577 let idx = match ui
1578 .select("Public tunnel model_provider", &options, current_idx)
1579 .await?
1580 {
1581 Answer::Back => return Ok(Nav::Back),
1582 Answer::Value(i) => i,
1583 };
1584 let new_model_provider = provider_names[idx].clone();
1585
1586 if new_model_provider != current_model_provider {
1587 persist(cfg, "tunnel.tunnel-provider", &new_model_provider).await?;
1588 }
1589
1590 if new_model_provider == "none" {
1591 break;
1592 }
1593
1594 let prefix = format!("tunnel.{new_model_provider}");
1595 cfg.init_defaults(Some(&prefix));
1596 cfg.mark_dirty(&prefix);
1597 cfg.save_dirty().await?;
1598 ui.heading(2, &new_model_provider);
1599 match prompt_fields_under(cfg, ui, &prefix, &[], &[]).await? {
1600 Nav::Back => continue,
1601 Nav::Done => break,
1602 }
1603 }
1604 mark_completed(cfg, Section::Tunnel).await?;
1605 Ok(Nav::Done)
1606}
1607
1608async fn agents(cfg: &mut Config, ui: &mut dyn OnboardUi, _flags: &Flags) -> Result<Nav> {
1609 emit_section_header(ui, Section::Agents, "Agents");
1610 loop {
1611 let existing_aliases: Vec<String> = cfg.get_map_keys("agents").unwrap_or_default();
1612 let mut options: Vec<SelectItem> = existing_aliases
1613 .iter()
1614 .map(|a| {
1615 let enabled_path = format!("agents.{a}.enabled");
1616 let is_active = cfg.get_prop(&enabled_path).ok().as_deref() == Some("true");
1617 if is_active {
1618 SelectItem::with_badge(a.clone(), "[active]")
1619 } else {
1620 SelectItem::with_badge(a.clone(), "[configured]")
1621 }
1622 })
1623 .collect();
1624 let add_new_idx = options.len();
1625 options.push(SelectItem::new("+ Add new"));
1626 let done_idx = options.len();
1627 options.push(SelectItem::new("Done"));
1628
1629 let idx = match ui.select("Agent", &options, Some(done_idx)).await? {
1630 Answer::Back => return Ok(Nav::Back),
1631 Answer::Value(i) => i,
1632 };
1633
1634 if idx == done_idx {
1635 break;
1636 }
1637
1638 let alias = if idx == add_new_idx {
1639 let suggestion = next_agent_alias_suggestion(&existing_aliases);
1640 let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1641 continue;
1642 };
1643 a
1644 } else {
1645 existing_aliases[idx].clone()
1646 };
1647
1648 cfg.create_map_key("agents", &alias).ok();
1649 cfg.mark_dirty(&format!("agents.{alias}"));
1650 cfg.save_dirty().await?;
1651 let workspace_dir = cfg.agent_workspace_dir(&alias);
1652 if let Err(err) = tokio::fs::create_dir_all(&workspace_dir).await {
1653 ui.warn(&format!(
1654 "Could not create agent workspace at {}: {err}",
1655 workspace_dir.display()
1656 ));
1657 } else if let Err(err) =
1658 zeroclaw_config::schema::ensure_bootstrap_files(&workspace_dir).await
1659 {
1660 ::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": alias, "workspace": workspace_dir.display().to_string(), "err": err.to_string()})), "bootstrap file seed failed (continuing): ");
1661 }
1662 ui.heading(2, &alias);
1663 let _ = prompt_agent_fields(cfg, ui, &alias).await?;
1664 }
1665 mark_completed(cfg, Section::Agents).await?;
1666 Ok(Nav::Done)
1667}
1668
1669async fn one_tier_alias_section(
1674 cfg: &mut Config,
1675 ui: &mut dyn OnboardUi,
1676 section: Section,
1677 section_path: &str,
1678 select_label: &str,
1679) -> Result<Nav> {
1680 emit_section_header(ui, section, select_label);
1681 loop {
1682 let existing: Vec<String> = cfg.get_map_keys(section_path).unwrap_or_default();
1683 let mut options: Vec<SelectItem> = existing
1684 .iter()
1685 .map(|a| SelectItem::new(a.clone()))
1686 .collect();
1687 let add_new_idx = options.len();
1688 options.push(SelectItem::new("+ Add new"));
1689 let done_idx = options.len();
1690 options.push(SelectItem::new("Done"));
1691
1692 let idx = match ui.select(select_label, &options, Some(done_idx)).await? {
1693 Answer::Back => return Ok(Nav::Back),
1694 Answer::Value(i) => i,
1695 };
1696
1697 if idx == done_idx {
1698 break;
1699 }
1700
1701 let alias = if idx == add_new_idx {
1702 let suggestion = next_agent_alias_suggestion(&existing);
1703 let Some(a) = prompt_alias_name(ui, &suggestion).await? else {
1704 continue;
1705 };
1706 a
1707 } else {
1708 existing[idx].clone()
1709 };
1710
1711 cfg.create_map_key(section_path, &alias).ok();
1712 let prefix = format!("{section_path}.{alias}");
1713 cfg.mark_dirty(&prefix);
1714 cfg.save_dirty().await?;
1715 ui.heading(2, &alias);
1716 let _ = prompt_fields_under(cfg, ui, &prefix, &[], &[]).await?;
1717 }
1718 mark_completed(cfg, section).await?;
1719 Ok(Nav::Done)
1720}
1721
1722async fn skills(cfg: &mut Config, ui: &mut dyn OnboardUi, _flags: &Flags) -> Result<Nav> {
1723 emit_section_header(ui, Section::Skills, "Skills");
1724 let nav = prompt_fields_under(cfg, ui, "skills", &[], &[]).await?;
1725 if matches!(nav, Nav::Back) {
1726 return Ok(Nav::Back);
1727 }
1728 mark_completed(cfg, Section::Skills).await?;
1729 Ok(Nav::Done)
1730}
1731
1732fn next_agent_alias_suggestion(existing: &[String]) -> String {
1737 if existing.is_empty() {
1738 return "default".to_string();
1739 }
1740 let base = existing[0].as_str();
1741 (2..)
1742 .map(|n| format!("{base}-{n}"))
1743 .find(|candidate| !existing.contains(candidate))
1744 .unwrap_or_else(|| format!("{base}-{}", existing.len() + 1))
1745}
1746
1747fn agent_field_path(alias: &str, snake_field: &str) -> String {
1755 let kebab = snake_field.replace('_', "-");
1756 format!("agents.{alias}.{kebab}")
1757}
1758
1759async fn prompt_agent_fields(cfg: &mut Config, ui: &mut dyn OnboardUi, alias: &str) -> Result<Nav> {
1765 let channel_aliases = available_channel_aliases(cfg);
1766 let provider_aliases = available_model_provider_aliases(cfg);
1767 let risk_aliases = cfg.get_map_keys("risk-profiles").unwrap_or_default();
1773 let runtime_aliases = cfg.get_map_keys("runtime-profiles").unwrap_or_default();
1774 let skill_aliases = cfg.get_map_keys("skill-bundles").unwrap_or_default();
1775 let knowledge_aliases = cfg.get_map_keys("knowledge-bundles").unwrap_or_default();
1776 let mcp_aliases = cfg.get_map_keys("mcp-bundles").unwrap_or_default();
1777
1778 let mut step: usize = 0;
1779 loop {
1780 let nav = match step {
1781 0 => prompt_field(cfg, ui, &agent_field_path(alias, "enabled"), None).await?,
1782 1 => prompt_agent_system_prompt(cfg, ui, alias).await?,
1783 2 => prompt_agent_alias_multi(cfg, ui, alias, "channels", &channel_aliases).await?,
1784 3 => {
1785 prompt_agent_alias_single(cfg, ui, alias, "model_provider", &provider_aliases)
1786 .await?
1787 }
1788 4 => prompt_agent_alias_single(cfg, ui, alias, "risk_profile", &risk_aliases).await?,
1789 5 => {
1790 prompt_agent_alias_single(cfg, ui, alias, "runtime_profile", &runtime_aliases)
1791 .await?
1792 }
1793 6 => prompt_agent_alias_multi(cfg, ui, alias, "skill_bundles", &skill_aliases).await?,
1794 7 => {
1795 prompt_agent_alias_multi(cfg, ui, alias, "knowledge_bundles", &knowledge_aliases)
1796 .await?
1797 }
1798 8 => prompt_agent_alias_multi(cfg, ui, alias, "mcp_bundles", &mcp_aliases).await?,
1799 _ => return Ok(Nav::Done),
1800 };
1801 match nav {
1802 Nav::Done => step += 1,
1803 Nav::Back => {
1804 if step == 0 {
1805 return Ok(Nav::Back);
1806 }
1807 step -= 1;
1808 }
1809 }
1810 }
1811}
1812
1813async fn prompt_agent_system_prompt(
1820 cfg: &Config,
1821 ui: &mut dyn OnboardUi,
1822 alias: &str,
1823) -> Result<Nav> {
1824 let workspace = cfg.agent_workspace_dir(alias);
1825 let template_ctx = TemplateContext {
1826 agent: alias.to_string(),
1827 include_memory: cfg.memory.backend.as_str() != "none",
1828 ..TemplateContext::default()
1829 };
1830
1831 loop {
1832 let mut items: Vec<SelectItem> = EDITABLE_PERSONALITY_FILES
1833 .iter()
1834 .map(|filename| {
1835 let exists = workspace.join(filename).is_file();
1836 SelectItem::with_badge(
1837 (*filename).to_string(),
1838 if exists { "saved" } else { "not saved" },
1839 )
1840 })
1841 .collect();
1842 items.push(SelectItem::new("Done"));
1843
1844 match ui.select("Personality file to edit", &items, None).await? {
1845 Answer::Back => return Ok(Nav::Back),
1846 Answer::Value(idx) if idx == EDITABLE_PERSONALITY_FILES.len() => break,
1847 Answer::Value(idx) => {
1848 let filename = EDITABLE_PERSONALITY_FILES[idx];
1849 let path = workspace.join(filename);
1850 let initial = if path.is_file() {
1851 tokio::fs::read_to_string(&path).await.unwrap_or_default()
1852 } else {
1853 render_personality(filename, &template_ctx).unwrap_or_default()
1854 };
1855 match ui.editor(&format!("Editing {filename}"), &initial).await? {
1856 Answer::Back => continue,
1857 Answer::Value(content) => {
1858 tokio::fs::create_dir_all(&workspace)
1859 .await
1860 .with_context(|| {
1861 format!(
1862 "Failed to create per-agent workspace at {}",
1863 workspace.display()
1864 )
1865 })?;
1866 tokio::fs::write(&path, content).await.with_context(|| {
1867 format!("Failed to write {} at {}", filename, path.display())
1868 })?;
1869 }
1870 }
1871 }
1872 }
1873 }
1874 Ok(Nav::Done)
1875}
1876
1877async fn prompt_agent_alias_single(
1881 cfg: &mut Config,
1882 ui: &mut dyn OnboardUi,
1883 alias: &str,
1884 field: &str,
1885 available: &[String],
1886) -> Result<Nav> {
1887 let path = agent_field_path(alias, field);
1888 let current_raw = cfg.get_prop(&path).ok().unwrap_or_default();
1889 let current = if current_raw == "<unset>" {
1890 String::new()
1891 } else {
1892 current_raw
1893 };
1894 let help = field_doc(cfg, &path).unwrap_or_default();
1895 ui.note(&help);
1896
1897 if available.is_empty() {
1898 ui.note(&format!(
1899 "{help}\nNo {field} aliases configured yet. Press Enter to leave empty."
1900 ));
1901 match ui.string(field, Some(¤t), None).await? {
1902 Answer::Back => return Ok(Nav::Back),
1903 Answer::Value(new) => {
1904 if new != current {
1905 persist(cfg, &path, &new).await?;
1906 }
1907 return Ok(Nav::Done);
1908 }
1909 }
1910 }
1911
1912 let mut items: Vec<SelectItem> = vec![SelectItem::new("(none)")];
1913 for a in available {
1914 items.push(SelectItem::new(a.as_str()));
1915 }
1916 let current_idx = if current.is_empty() {
1917 Some(0)
1918 } else {
1919 available
1920 .iter()
1921 .position(|a| a == ¤t)
1922 .map(|i| i + 1)
1923 .or(Some(0))
1924 };
1925 match ui.select(field, &items, current_idx).await? {
1926 Answer::Back => Ok(Nav::Back),
1927 Answer::Value(0) => {
1928 if !current.is_empty() {
1929 persist(cfg, &path, "").await?;
1930 }
1931 Ok(Nav::Done)
1932 }
1933 Answer::Value(i) => {
1934 let chosen = &available[i - 1];
1935 if chosen != ¤t {
1936 persist(cfg, &path, chosen).await?;
1937 }
1938 Ok(Nav::Done)
1939 }
1940 }
1941}
1942
1943async fn prompt_agent_alias_multi(
1952 cfg: &mut Config,
1953 ui: &mut dyn OnboardUi,
1954 alias: &str,
1955 field: &str,
1956 available: &[String],
1957) -> Result<Nav> {
1958 let path = agent_field_path(alias, field);
1959 let current_raw = cfg.get_prop(&path).ok().unwrap_or_default();
1960 let initial = parse_string_array_display(¤t_raw);
1961 let mut selected: Vec<String> = initial
1965 .iter()
1966 .filter(|s| available.iter().any(|a| a == *s))
1967 .cloned()
1968 .collect();
1969 let help = field_doc(cfg, &path).unwrap_or_default();
1970
1971 if available.is_empty() {
1972 ui.note(&format!(
1973 "{help}\nNo {field} aliases configured yet — skipping."
1974 ));
1975 return Ok(Nav::Done);
1976 }
1977
1978 loop {
1979 ui.note(&format!(
1980 "{help}\nEnter toggles a row. Pick `Done` to commit. ({} of {} selected)",
1981 selected.len(),
1982 available.len(),
1983 ));
1984
1985 let mut items: Vec<SelectItem> = available
1986 .iter()
1987 .map(|a| {
1988 let is_selected = selected.contains(a);
1989 let label = format!("[{}] {a}", if is_selected { "x" } else { " " });
1990 if is_selected {
1991 SelectItem::with_badge(label, "selected")
1992 } else {
1993 SelectItem::new(label)
1994 }
1995 })
1996 .collect();
1997 items.push(SelectItem::new("Done"));
1998 let done_idx = items.len() - 1;
1999
2000 match ui.select(field, &items, Some(done_idx)).await? {
2001 Answer::Back => return Ok(Nav::Back),
2002 Answer::Value(i) if i == done_idx => {
2003 let serialized = serialize_string_array_json(&selected);
2004 if serialized != current_raw {
2005 persist(cfg, &path, &serialized).await?;
2006 }
2007 return Ok(Nav::Done);
2008 }
2009 Answer::Value(i) => {
2010 let alias_at = &available[i];
2011 if let Some(pos) = selected.iter().position(|a| a == alias_at) {
2012 selected.remove(pos);
2013 } else {
2014 selected.push(alias_at.clone());
2015 }
2016 }
2017 }
2018 }
2019}
2020
2021fn field_doc(cfg: &Config, path: &str) -> Option<String> {
2022 cfg.prop_fields()
2023 .into_iter()
2024 .find(|f| f.name == path)
2025 .map(|f| f.description.to_string())
2026}
2027
2028fn parse_string_array_display(s: &str) -> Vec<String> {
2032 let trimmed = s.trim();
2033 if trimmed.is_empty() || trimmed == "<unset>" || trimmed == "[]" {
2034 return Vec::new();
2035 }
2036 if trimmed.starts_with('[')
2037 && let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed)
2038 {
2039 return arr;
2040 }
2041 trimmed
2042 .split(',')
2043 .map(|s| s.trim().to_string())
2044 .filter(|s| !s.is_empty())
2045 .collect()
2046}
2047
2048fn serialize_string_array_json(items: &[String]) -> String {
2049 serde_json::to_string(items).unwrap_or_else(|_| "[]".to_string())
2050}
2051
2052fn available_channel_aliases(cfg: &Config) -> Vec<String> {
2056 let mut out: Vec<String> = Vec::new();
2057 for f in cfg.prop_fields() {
2058 if let Some(rest) = f.name.strip_prefix("channels.") {
2059 let mut parts = rest.splitn(3, '.');
2060 if let (Some(ty), Some(alias), Some(_leaf)) = (parts.next(), parts.next(), parts.next())
2061 {
2062 let dotted = format!("{ty}.{alias}");
2063 if !out.contains(&dotted) {
2064 out.push(dotted);
2065 }
2066 }
2067 }
2068 }
2069 out.sort();
2070 out
2071}
2072
2073fn available_model_provider_aliases(cfg: &Config) -> Vec<String> {
2076 let mut out: Vec<String> = Vec::new();
2077 for f in cfg.prop_fields() {
2078 if let Some(rest) = f.name.strip_prefix("providers.models.") {
2079 let mut parts = rest.splitn(3, '.');
2080 if let (Some(ty), Some(alias), Some(_leaf)) = (parts.next(), parts.next(), parts.next())
2081 {
2082 let dotted = format!("{ty}.{alias}");
2083 if !out.contains(&dotted) {
2084 out.push(dotted);
2085 }
2086 }
2087 }
2088 }
2089 out.sort();
2090 out
2091}
2092
2093#[cfg(test)]
2094mod tests {
2095 use super::*;
2096 use crate::onboard::ui::quick::QuickUi;
2097 use axum::Router;
2098 use axum::http::{StatusCode, header};
2099 use axum::routing::get;
2100 use std::sync::Arc;
2101 use tempfile::TempDir;
2102 use tokio::net::TcpListener;
2103 use zeroclaw_config::schema::{
2104 AnthropicModelProviderConfig, Config, ModelProviderConfig, WireApi,
2105 };
2106
2107 #[test]
2108 fn next_agent_alias_suggestion_handles_empty_collision_and_growth() {
2109 assert_eq!(next_agent_alias_suggestion(&[]), "default");
2111
2112 let one = vec!["assistant".to_string()];
2114 assert_eq!(next_agent_alias_suggestion(&one), "assistant-2");
2115
2116 let two = vec!["assistant".to_string(), "assistant-2".to_string()];
2118 assert_eq!(next_agent_alias_suggestion(&two), "assistant-3");
2119
2120 let four = vec![
2122 "researcher".to_string(),
2123 "researcher-2".to_string(),
2124 "researcher-3".to_string(),
2125 "researcher-5".to_string(),
2126 ];
2127 assert_eq!(next_agent_alias_suggestion(&four), "researcher-4");
2128 }
2129
2130 fn test_cfg(temp: &TempDir) -> Config {
2133 Config {
2134 config_path: temp.path().join("config.toml"),
2135 data_dir: temp.path().join("data"),
2136 ..Default::default()
2137 }
2138 }
2139
2140 async fn spawn_models_endpoint(
2141 status: StatusCode,
2142 body: &'static str,
2143 delay: Option<Duration>,
2144 ) -> String {
2145 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
2146 let port = listener.local_addr().unwrap().port();
2147 let body = Arc::new(body.to_string());
2148 let app = Router::new().route(
2149 "/v1/models",
2150 get(move || {
2151 let body = body.clone();
2152 async move {
2153 if let Some(delay) = delay {
2154 tokio::time::sleep(delay).await;
2155 }
2156 (
2157 status,
2158 [(header::CONTENT_TYPE, "application/json")],
2159 body.to_string(),
2160 )
2161 }
2162 }),
2163 );
2164 tokio::spawn(async move {
2165 axum::serve(listener, app).await.unwrap();
2166 });
2167 format!("http://127.0.0.1:{port}")
2168 }
2169
2170 #[tokio::test]
2171 async fn section_has_signal_providers_requires_models_entry() {
2172 let temp = TempDir::new().unwrap();
2173 let mut cfg = test_cfg(&temp);
2174 assert!(!section_has_signal(&cfg, Section::ModelProviders));
2175 cfg.providers
2176 .models
2177 .ensure("anthropic", "default")
2178 .expect("anthropic typed slot");
2179 assert!(section_has_signal(&cfg, Section::ModelProviders));
2180 }
2181
2182 #[tokio::test]
2183 async fn section_has_signal_hardware_tracks_enabled_flag() {
2184 let temp = TempDir::new().unwrap();
2185 let mut cfg = test_cfg(&temp);
2186 assert!(!section_has_signal(&cfg, Section::Hardware));
2187 cfg.hardware.enabled = true;
2188 assert!(section_has_signal(&cfg, Section::Hardware));
2189 }
2190
2191 #[tokio::test]
2192 async fn section_has_signal_memory_and_tunnel_are_marker_only() {
2193 let temp = TempDir::new().unwrap();
2194 let cfg = test_cfg(&temp);
2195 assert!(!section_has_signal(&cfg, Section::Memory));
2199 assert!(!section_has_signal(&cfg, Section::Tunnel));
2200 }
2201
2202 #[tokio::test]
2203 async fn mark_completed_is_dedupe_safe() {
2204 let temp = TempDir::new().unwrap();
2205 let mut cfg = test_cfg(&temp);
2206 mark_completed(&mut cfg, Section::Memory).await.unwrap();
2207 mark_completed(&mut cfg, Section::Memory).await.unwrap();
2208 let count = cfg
2209 .onboard_state
2210 .completed_sections
2211 .iter()
2212 .filter(|s| s.as_str() == "memory")
2213 .count();
2214 assert_eq!(count, 1, "marker should be inserted at most once");
2215 }
2216
2217 #[tokio::test]
2218 async fn skip_gate_skips_when_marked_and_user_declines() {
2219 let temp = TempDir::new().unwrap();
2220 let mut cfg = test_cfg(&temp);
2221 cfg.onboard_state.completed_sections.push("memory".into());
2222
2223 let mut ui = QuickUi::new();
2226 let result = skip_if_configured(
2227 &cfg,
2228 &mut ui,
2229 &Flags::default(),
2230 Section::Memory,
2231 "Memory",
2232 false,
2233 )
2234 .await
2235 .unwrap();
2236 assert_eq!(result, SkipNav::Skip);
2237 }
2238
2239 #[tokio::test]
2240 async fn skip_gate_skips_when_signal_present_and_user_declines() {
2241 let temp = TempDir::new().unwrap();
2242 let cfg = test_cfg(&temp);
2243 let mut ui = QuickUi::new();
2245 let result = skip_if_configured(
2246 &cfg,
2247 &mut ui,
2248 &Flags::default(),
2249 Section::Memory,
2250 "Memory",
2251 true,
2252 )
2253 .await
2254 .unwrap();
2255 assert_eq!(result, SkipNav::Skip);
2256 }
2257
2258 #[tokio::test]
2259 async fn skip_gate_enters_when_force_flag_set() {
2260 let temp = TempDir::new().unwrap();
2261 let mut cfg = test_cfg(&temp);
2262 cfg.onboard_state.completed_sections.push("memory".into());
2263
2264 let mut ui = QuickUi::new();
2265 let flags = Flags {
2266 force: true,
2267 ..Default::default()
2268 };
2269 let result = skip_if_configured(&cfg, &mut ui, &flags, Section::Memory, "Memory", true)
2270 .await
2271 .unwrap();
2272 assert_eq!(result, SkipNav::Enter);
2273 }
2274
2275 #[tokio::test]
2276 async fn skip_gate_enters_when_unmarked_and_no_signal() {
2277 let temp = TempDir::new().unwrap();
2278 let cfg = test_cfg(&temp);
2279 let mut ui = QuickUi::new();
2280 let result = skip_if_configured(
2281 &cfg,
2282 &mut ui,
2283 &Flags::default(),
2284 Section::Memory,
2285 "Memory",
2286 false,
2287 )
2288 .await
2289 .unwrap();
2290 assert_eq!(result, SkipNav::Enter);
2291 }
2292
2293 #[tokio::test]
2294 async fn discover_openai_compat_models_parses_valid_models_payload() {
2295 let base_url = spawn_models_endpoint(
2296 StatusCode::OK,
2297 r#"{"object":"list","data":[{"id":"llama-3.3"},{"id":" qwen3-coder "}]}"#,
2298 None,
2299 )
2300 .await;
2301
2302 let models = discover_openai_compat_models(&base_url, Some("sk-test"))
2303 .await
2304 .unwrap();
2305
2306 assert_eq!(models, vec!["llama-3.3", "qwen3-coder"]);
2307 }
2308
2309 #[tokio::test]
2310 async fn discover_openai_compat_models_rejects_malformed_json() {
2311 let base_url = spawn_models_endpoint(StatusCode::OK, r#"{"data":["#, None).await;
2312
2313 let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2314 .await
2315 .unwrap_err()
2316 .to_string();
2317
2318 assert!(
2319 err.contains("invalid JSON"),
2320 "unexpected discovery error: {err}"
2321 );
2322 }
2323
2324 #[tokio::test]
2325 async fn discover_openai_compat_models_reports_unauthorized() {
2326 let base_url =
2327 spawn_models_endpoint(StatusCode::UNAUTHORIZED, r#"{"error":"bad key"}"#, None).await;
2328
2329 let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2330 .await
2331 .unwrap_err()
2332 .to_string();
2333
2334 assert!(
2335 err.contains("HTTP 401"),
2336 "unexpected discovery error: {err}"
2337 );
2338 }
2339
2340 #[tokio::test]
2341 async fn discover_openai_compat_models_reports_not_found() {
2342 let base_url =
2343 spawn_models_endpoint(StatusCode::NOT_FOUND, r#"{"error":"nope"}"#, None).await;
2344
2345 let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2346 .await
2347 .unwrap_err()
2348 .to_string();
2349
2350 assert!(
2351 err.contains("HTTP 404"),
2352 "unexpected discovery error: {err}"
2353 );
2354 }
2355
2356 #[tokio::test]
2357 async fn discover_openai_compat_models_reports_server_error() {
2358 let base_url = spawn_models_endpoint(
2359 StatusCode::INTERNAL_SERVER_ERROR,
2360 r#"{"error":"boom"}"#,
2361 None,
2362 )
2363 .await;
2364
2365 let err = discover_openai_compat_models(&base_url, Some("sk-test"))
2366 .await
2367 .unwrap_err()
2368 .to_string();
2369
2370 assert!(
2371 err.contains("HTTP 500"),
2372 "unexpected discovery error: {err}"
2373 );
2374 }
2375
2376 #[tokio::test]
2377 async fn discover_openai_compat_models_reports_network_timeout() {
2378 let base_url = spawn_models_endpoint(
2379 StatusCode::OK,
2380 r#"{"data":[{"id":"slow-model"}]}"#,
2381 Some(Duration::from_millis(200)),
2382 )
2383 .await;
2384
2385 let err = discover_openai_compat_models_with_timeout(
2386 &base_url,
2387 Some("sk-test"),
2388 Duration::from_millis(50),
2389 )
2390 .await
2391 .unwrap_err()
2392 .to_string();
2393
2394 assert!(
2395 err.contains("request failed"),
2396 "unexpected discovery error: {err}"
2397 );
2398 }
2399
2400 #[tokio::test]
2401 async fn providers_custom_openai_endpoint_discovers_models() {
2402 let temp = TempDir::new().unwrap();
2403 let mut cfg = test_cfg(&temp);
2404 let base_url = spawn_models_endpoint(
2405 StatusCode::OK,
2406 r#"{"data":[{"id":"llama-local"},{"id":"qwen-local"}]}"#,
2407 None,
2408 )
2409 .await;
2410
2411 let flags = Flags::default();
2412 let mut ui = QuickUi::new()
2413 .with("ModelProvider", CUSTOM_OPENAI_COMPAT_LABEL)
2414 .with("OpenAI-compatible base URL", &base_url)
2415 .with("alias", "default")
2416 .with("api-key", "sk-custom-test")
2417 .with("Model", "qwen-local");
2418
2419 Box::pin(run(
2420 &mut cfg,
2421 &mut ui,
2422 Some(Section::ModelProviders),
2423 &flags,
2424 ))
2425 .await
2426 .unwrap();
2427
2428 let model_cfg = cfg
2429 .providers
2430 .models
2431 .find("custom", "default")
2432 .expect("custom model_provider entry should be seeded");
2433 assert_eq!(model_cfg.api_key.as_deref(), Some("sk-custom-test"));
2434 assert_eq!(model_cfg.uri.as_deref(), Some(base_url.as_str()));
2435 assert_eq!(model_cfg.model.as_deref(), Some("qwen-local"));
2436 }
2437
2438 #[tokio::test]
2439 async fn prompt_model_unknown_provider_with_base_url_discovers_models() {
2440 let temp = TempDir::new().unwrap();
2441 let mut cfg = test_cfg(&temp);
2442 let base_url = spawn_models_endpoint(
2443 StatusCode::OK,
2444 r#"{"data":[{"id":"gateway-small"},{"id":"gateway-large"}]}"#,
2445 None,
2446 )
2447 .await;
2448 let entry = cfg
2449 .providers
2450 .models
2451 .ensure("custom", "default")
2452 .expect("custom typed slot");
2453 entry.api_key = Some("sk-gateway-test".into());
2454 entry.uri = Some(base_url);
2455 let mut ui = QuickUi::new().with("Model", "gateway-large");
2456
2457 prompt_model(&mut cfg, &mut ui, "providers.models.custom.default")
2458 .await
2459 .unwrap();
2460
2461 let model_cfg = cfg
2462 .providers
2463 .models
2464 .find("custom", "default")
2465 .expect("custom model_provider entry should remain configured");
2466 assert_eq!(model_cfg.model.as_deref(), Some("gateway-large"));
2467 }
2468
2469 #[tokio::test]
2476 async fn providers_forced_via_flags_persists_and_marks_completed() {
2477 let temp = TempDir::new().unwrap();
2478 let mut cfg = test_cfg(&temp);
2479
2480 let flags = Flags {
2481 model_provider: Some("anthropic".into()),
2482 api_key: Some("sk-ant-test".into()),
2483 model: Some("claude-opus-4-7".into()),
2484 ..Default::default()
2485 };
2486 let mut ui = QuickUi::new();
2487 Box::pin(run(
2488 &mut cfg,
2489 &mut ui,
2490 Some(Section::ModelProviders),
2491 &flags,
2492 ))
2493 .await
2494 .unwrap();
2495
2496 let model_cfg = cfg
2497 .providers
2498 .models
2499 .find("anthropic", "default")
2500 .expect("anthropic.default entry should be seeded");
2501 assert_eq!(model_cfg.model.as_deref(), Some("claude-opus-4-7"));
2502 assert_eq!(model_cfg.api_key.as_deref(), Some("sk-ant-test"));
2503 assert!(
2504 cfg.onboard_state
2505 .completed_sections
2506 .iter()
2507 .any(|s| s == "providers.models"),
2508 "providers.models section should mark completed"
2509 );
2510 }
2511
2512 #[tokio::test]
2517 async fn providers_second_run_no_flags_is_idempotent_on_disk() {
2518 let temp = TempDir::new().unwrap();
2519 let mut cfg = test_cfg(&temp);
2520
2521 let prime = Flags {
2522 model_provider: Some("anthropic".into()),
2523 api_key: Some("sk-ant-test".into()),
2524 model: Some("claude-opus-4-7".into()),
2525 ..Default::default()
2526 };
2527 let mut ui = QuickUi::new();
2528 Box::pin(run(
2529 &mut cfg,
2530 &mut ui,
2531 Some(Section::ModelProviders),
2532 &prime,
2533 ))
2534 .await
2535 .unwrap();
2536 let after_first = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2537
2538 let mut ui = QuickUi::new();
2539 run(
2540 &mut cfg,
2541 &mut ui,
2542 Some(Section::ModelProviders),
2543 &Flags::default(),
2544 )
2545 .await
2546 .unwrap();
2547 let after_second = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2548 assert_eq!(
2549 after_first, after_second,
2550 "second run hit the skip-gate and must not rewrite config.toml"
2551 );
2552 }
2553
2554 #[tokio::test]
2559 async fn channels_done_selection_is_idempotent_on_disk() {
2560 let temp = TempDir::new().unwrap();
2561 let mut cfg = test_cfg(&temp);
2562 let flags = Flags::default();
2563
2564 let mut ui = QuickUi::new();
2565 Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2566 .await
2567 .unwrap();
2568
2569 assert!(
2570 cfg.onboard_state
2571 .completed_sections
2572 .iter()
2573 .any(|s| s == "channels"),
2574 "first run should mark channels completed"
2575 );
2576 let after_first = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2577
2578 let mut ui = QuickUi::new();
2579 Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2580 .await
2581 .unwrap();
2582 let after_second = tokio::fs::read_to_string(&cfg.config_path).await.unwrap();
2583 assert_eq!(
2584 after_first, after_second,
2585 "second run hit the skip-gate and must not rewrite config.toml"
2586 );
2587 }
2588
2589 #[tokio::test]
2594 async fn channels_telegram_selection_writes_entry() {
2595 let temp = TempDir::new().unwrap();
2596 let mut cfg = test_cfg(&temp);
2597 let flags = Flags::default();
2598
2599 let mut ui = QuickUi::new()
2600 .with("bot-token", "stub-tg-token")
2601 .with("proxy-url", "")
2606 .with("default-target", "")
2607 .with("excluded-tools", "")
2610 .with_sequence("Channel", ["telegram", "Done"]);
2611 Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2612 .await
2613 .unwrap();
2614
2615 let tg = cfg
2616 .channels
2617 .telegram
2618 .get("default")
2619 .expect("telegram subsection should be initialized");
2620 assert_eq!(tg.bot_token, "stub-tg-token");
2621 assert!(
2622 cfg.onboard_state
2623 .completed_sections
2624 .iter()
2625 .any(|s| s == "channels"),
2626 "channels section should mark completed"
2627 );
2628 }
2629
2630 #[tokio::test]
2634 async fn channels_mochat_selection_persists_url_and_token() {
2635 let temp = TempDir::new().unwrap();
2636 let mut cfg = test_cfg(&temp);
2637 let flags = Flags::default();
2638
2639 let mut ui = QuickUi::new()
2640 .with("api-url", "http://mochat-test:8080/v1")
2641 .with("api-token", "stub-mochat-token")
2642 .with("excluded-tools", "")
2645 .with_sequence("Channel", ["mochat", "Done"]);
2646 Box::pin(run(&mut cfg, &mut ui, Some(Section::Channels), &flags))
2647 .await
2648 .unwrap();
2649
2650 let mc = cfg
2651 .channels
2652 .mochat
2653 .get("default")
2654 .expect("mochat subsection should be initialized");
2655 assert_eq!(mc.api_url, "http://mochat-test:8080/v1");
2656 assert_eq!(mc.api_token, "stub-mochat-token");
2657 }
2658
2659 struct BackAt {
2666 back_prompt: &'static str,
2667 inner: QuickUi,
2668 }
2669
2670 impl BackAt {
2671 fn new(back_prompt: &'static str, inner: QuickUi) -> Self {
2672 Self { back_prompt, inner }
2673 }
2674 }
2675
2676 #[async_trait::async_trait]
2677 impl OnboardUi for BackAt {
2678 async fn confirm(&mut self, prompt: &str, default: bool) -> anyhow::Result<Answer<bool>> {
2679 if prompt == self.back_prompt {
2680 return Ok(Answer::Back);
2681 }
2682 self.inner.confirm(prompt, default).await
2683 }
2684
2685 async fn string(
2686 &mut self,
2687 prompt: &str,
2688 current: Option<&str>,
2689 placeholder: Option<&str>,
2690 ) -> anyhow::Result<Answer<String>> {
2691 if prompt == self.back_prompt {
2692 return Ok(Answer::Back);
2693 }
2694 self.inner.string(prompt, current, placeholder).await
2695 }
2696
2697 async fn secret(
2698 &mut self,
2699 prompt: &str,
2700 has_current: bool,
2701 ) -> anyhow::Result<Answer<Option<String>>> {
2702 if prompt == self.back_prompt {
2703 return Ok(Answer::Back);
2704 }
2705 self.inner.secret(prompt, has_current).await
2706 }
2707
2708 async fn select(
2709 &mut self,
2710 prompt: &str,
2711 items: &[SelectItem],
2712 current: Option<usize>,
2713 ) -> anyhow::Result<Answer<usize>> {
2714 if prompt == self.back_prompt {
2715 return Ok(Answer::Back);
2716 }
2717 self.inner.select(prompt, items, current).await
2718 }
2719
2720 async fn editor(&mut self, hint: &str, initial: &str) -> anyhow::Result<Answer<String>> {
2721 if hint == self.back_prompt {
2722 return Ok(Answer::Back);
2723 }
2724 self.inner.editor(hint, initial).await
2725 }
2726
2727 fn heading(&mut self, level: u8, text: &str) {
2728 self.inner.heading(level, text);
2729 }
2730
2731 fn note(&mut self, msg: &str) {
2732 self.inner.note(msg);
2733 }
2734
2735 fn status(&mut self, msg: &str) {
2736 self.inner.status(msg);
2737 }
2738
2739 fn warn(&mut self, msg: &str) {
2740 self.inner.warn(msg);
2741 }
2742 }
2743
2744 #[tokio::test]
2749 async fn prompt_model_writes_to_actual_alias_not_hardcoded_default() {
2750 let temp = TempDir::new().unwrap();
2751 let mut cfg = test_cfg(&temp);
2752 cfg.providers
2753 .models
2754 .anthropic
2755 .insert("work".into(), AnthropicModelProviderConfig::default());
2756
2757 let mut ui = QuickUi::new().with("Model", "claude-opus-4-7");
2758 prompt_model(&mut cfg, &mut ui, "providers.models.anthropic.work")
2759 .await
2760 .unwrap();
2761
2762 let work_model = cfg
2763 .providers
2764 .models
2765 .find("anthropic", "work")
2766 .and_then(|c| c.model.as_deref());
2767 assert_eq!(
2768 work_model,
2769 Some("claude-opus-4-7"),
2770 "model must be written to the 'work' alias, not 'default'"
2771 );
2772
2773 let default_model = cfg
2774 .providers
2775 .models
2776 .find("anthropic", "default")
2777 .and_then(|c| c.model.as_deref());
2778 assert!(
2779 default_model.is_none(),
2780 "no 'default' alias should exist — path was hardcoded to 'default' (regression)"
2781 );
2782 }
2783
2784 #[tokio::test]
2787 async fn providers_esc_on_existing_alias_leaves_config_untouched() {
2788 let temp = TempDir::new().unwrap();
2789 let mut cfg = test_cfg(&temp);
2790
2791 cfg.providers.models.anthropic.insert(
2793 "my-alias".to_string(),
2794 AnthropicModelProviderConfig {
2795 base: ModelProviderConfig {
2796 api_key: Some("sk-original".into()),
2797 model: Some("claude-opus-4-7".into()),
2798 ..Default::default()
2799 },
2800 },
2801 );
2802
2803 let mut ui = BackAt::new(
2807 "api-key",
2808 QuickUi::new()
2809 .with_sequence("ModelProvider", ["Anthropic", "Done"])
2810 .with("Alias", "my-alias"),
2811 );
2812 run(
2813 &mut cfg,
2814 &mut ui,
2815 Some(Section::ModelProviders),
2816 &Flags::default(),
2817 )
2818 .await
2819 .unwrap();
2820
2821 let alias_cfg = cfg
2822 .providers
2823 .models
2824 .find("anthropic", "my-alias")
2825 .expect("my-alias must survive ESC on an existing entry");
2826 assert_eq!(
2827 alias_cfg.api_key.as_deref(),
2828 Some("sk-original"),
2829 "original api_key must not be clobbered after ESC"
2830 );
2831 assert_eq!(alias_cfg.model.as_deref(), Some("claude-opus-4-7"));
2832 }
2833
2834 #[tokio::test]
2837 async fn providers_esc_on_new_alias_removes_entry() {
2838 let temp = TempDir::new().unwrap();
2839 let mut cfg = test_cfg(&temp);
2840
2841 let mut ui = BackAt::new(
2845 "api-key",
2846 QuickUi::new()
2847 .with_sequence("ModelProvider", ["Anthropic", "Done"])
2848 .with("Alias (name for this configuration)", "fresh"),
2849 );
2850 run(
2851 &mut cfg,
2852 &mut ui,
2853 Some(Section::ModelProviders),
2854 &Flags::default(),
2855 )
2856 .await
2857 .unwrap();
2858
2859 let entry = cfg.providers.models.find("anthropic", "fresh");
2860 assert!(
2861 entry.is_none(),
2862 "in-progress 'fresh' alias must be removed after ESC (never persisted)"
2863 );
2864 }
2865
2866 #[test]
2871 fn create_map_key_rejects_alias_with_dot() {
2872 let temp = TempDir::new().unwrap();
2873 let mut cfg = test_cfg(&temp);
2874 let result = cfg.create_map_key("channels.discord", "my.alias");
2875 assert!(result.is_err(), "dot in alias must be rejected");
2876 assert!(
2877 cfg.channels.discord.is_empty(),
2878 "no entry should be inserted"
2879 );
2880 }
2881
2882 #[test]
2883 fn create_map_key_rejects_alias_with_slash() {
2884 let temp = TempDir::new().unwrap();
2885 let mut cfg = test_cfg(&temp);
2886 let result = cfg.create_map_key("channels.discord", "prod/main");
2887 assert!(result.is_err(), "slash in alias must be rejected");
2888 }
2889
2890 #[test]
2891 fn create_map_key_rejects_alias_with_space() {
2892 let temp = TempDir::new().unwrap();
2893 let mut cfg = test_cfg(&temp);
2894 let result = cfg.create_map_key("channels.discord", "my alias");
2895 assert!(result.is_err(), "space in alias must be rejected");
2896 }
2897
2898 #[test]
2899 fn create_map_key_rejects_alias_starting_with_hyphen() {
2900 let temp = TempDir::new().unwrap();
2901 let mut cfg = test_cfg(&temp);
2902 let result = cfg.create_map_key("channels.discord", "-bad");
2903 assert!(result.is_err(), "leading hyphen in alias must be rejected");
2904 }
2905
2906 #[test]
2907 fn create_map_key_accepts_valid_alias() {
2908 let temp = TempDir::new().unwrap();
2909 let mut cfg = test_cfg(&temp);
2910 let result = cfg.create_map_key("channels.discord", "prodalerts");
2913 assert!(result.is_ok(), "valid alias must be accepted");
2914 assert!(cfg.channels.discord.contains_key("prodalerts"));
2915 }
2916
2917 #[test]
2918 fn create_map_key_rejects_invalid_on_providers_double_nested() {
2919 let temp = TempDir::new().unwrap();
2920 let mut cfg = test_cfg(&temp);
2921 let result = cfg.create_map_key("providers.models.anthropic", "my.alias");
2926 assert!(
2927 result.is_err(),
2928 "dot in double-nested alias must be rejected"
2929 );
2930 assert!(
2931 cfg.providers.models.find("anthropic", "my.alias").is_none(),
2932 "no entry should be inserted into the inner map"
2933 );
2934 }
2935
2936 #[tokio::test]
2940 async fn get_map_keys_returns_all_channel_aliases() {
2941 use zeroclaw_config::schema::DiscordConfig;
2942
2943 let temp = TempDir::new().unwrap();
2944 let mut cfg = test_cfg(&temp);
2945 cfg.channels
2946 .discord
2947 .insert("default".into(), DiscordConfig::default());
2948 cfg.channels
2949 .discord
2950 .insert("alerts".into(), DiscordConfig::default());
2951
2952 let mut keys = cfg
2953 .get_map_keys("channels.discord")
2954 .expect("discord has two entries — get_map_keys must return Some");
2955 keys.sort();
2956 assert_eq!(keys, vec!["alerts", "default"]);
2957 }
2958
2959 #[tokio::test]
2962 async fn get_map_keys_returns_all_provider_aliases() {
2963 let temp = TempDir::new().unwrap();
2964 let mut cfg = test_cfg(&temp);
2965 cfg.providers
2966 .models
2967 .anthropic
2968 .insert("default".into(), AnthropicModelProviderConfig::default());
2969 cfg.providers
2970 .models
2971 .anthropic
2972 .insert("work".into(), AnthropicModelProviderConfig::default());
2973
2974 let mut keys = cfg
2975 .get_map_keys("providers.models.anthropic")
2976 .expect("anthropic has two aliases — get_map_keys must return Some");
2977 keys.sort();
2978 assert_eq!(keys, vec!["default", "work"]);
2979 }
2980
2981 #[tokio::test]
2990 async fn openai_codex_subscription_auth_sets_flags() {
2991 let temp = TempDir::new().unwrap();
2992 let mut cfg = test_cfg(&temp);
2993
2994 let flags = Flags {
2995 model: Some("codex-mini-latest".into()),
2996 ..Default::default()
2997 };
2998 let mut ui = QuickUi::new()
2999 .with("ModelProvider", "OpenAI")
3000 .with(
3002 i18n::get_required_cli_string("onboard-openai-auth-prompt"),
3003 i18n::get_required_cli_string("onboard-openai-auth-codex"),
3004 );
3005
3006 Box::pin(run(
3007 &mut cfg,
3008 &mut ui,
3009 Some(Section::ModelProviders),
3010 &flags,
3011 ))
3012 .await
3013 .unwrap();
3014
3015 let entry = cfg
3016 .providers
3017 .models
3018 .find("openai", "default")
3019 .expect("openai.default entry should be seeded");
3020 assert!(
3021 entry.requires_openai_auth,
3022 "requires_openai_auth must be true for Codex subscription"
3023 );
3024 assert_eq!(
3025 entry.wire_api,
3026 Some(WireApi::Responses),
3027 "wire_api must be Responses for Codex subscription"
3028 );
3029 assert_eq!(entry.model.as_deref(), Some("codex-mini-latest"));
3030 assert!(
3031 entry.api_key.is_none(),
3032 "Codex subscription must not prompt for or store an API key"
3033 );
3034 }
3035
3036 #[tokio::test]
3039 async fn openai_api_key_auth_clears_codex_flags() {
3040 use zeroclaw_config::schema::OpenAIModelProviderConfig;
3041
3042 let temp = TempDir::new().unwrap();
3043 let mut cfg = test_cfg(&temp);
3044
3045 cfg.providers.models.openai.insert(
3047 "default".to_string(),
3048 OpenAIModelProviderConfig {
3049 base: ModelProviderConfig {
3050 requires_openai_auth: true,
3051 wire_api: Some(WireApi::Responses),
3052 model: Some("codex-mini-latest".into()),
3053 ..Default::default()
3054 },
3055 },
3056 );
3057
3058 let flags = Flags {
3059 model: Some("gpt-4o".into()),
3060 ..Default::default()
3061 };
3062 let mut ui = QuickUi::new()
3063 .with("ModelProvider", "OpenAI")
3064 .with("Alias", "default")
3065 .with(
3066 i18n::get_required_cli_string("onboard-openai-auth-prompt"),
3067 i18n::get_required_cli_string("onboard-openai-auth-api-key"),
3068 )
3069 .with("api-key", "sk-test-key");
3070
3071 Box::pin(run(
3072 &mut cfg,
3073 &mut ui,
3074 Some(Section::ModelProviders),
3075 &flags,
3076 ))
3077 .await
3078 .unwrap();
3079
3080 let entry = cfg
3081 .providers
3082 .models
3083 .find("openai", "default")
3084 .expect("openai.default entry should remain configured");
3085 assert!(
3086 !entry.requires_openai_auth,
3087 "requires_openai_auth must be false after switching to API key"
3088 );
3089 assert_eq!(entry.api_key.as_deref(), Some("sk-test-key"));
3090 }
3091
3092 #[tokio::test]
3096 async fn openai_forced_api_key_flag_clears_codex_auth() {
3097 use zeroclaw_config::schema::OpenAIModelProviderConfig;
3098
3099 let temp = TempDir::new().unwrap();
3100 let mut cfg = test_cfg(&temp);
3101
3102 cfg.providers.models.openai.insert(
3104 "default".to_string(),
3105 OpenAIModelProviderConfig {
3106 base: ModelProviderConfig {
3107 requires_openai_auth: true,
3108 wire_api: Some(WireApi::Responses),
3109 model: Some("codex-mini-latest".into()),
3110 ..Default::default()
3111 },
3112 },
3113 );
3114
3115 let flags = Flags {
3118 model_provider: Some("openai".into()),
3119 api_key: Some("sk-forced-key".into()),
3120 model: Some("gpt-4o".into()),
3121 ..Default::default()
3122 };
3123 let mut ui = QuickUi::new();
3124
3125 Box::pin(run(
3126 &mut cfg,
3127 &mut ui,
3128 Some(Section::ModelProviders),
3129 &flags,
3130 ))
3131 .await
3132 .unwrap();
3133
3134 let entry = cfg
3135 .providers
3136 .models
3137 .find("openai", "default")
3138 .expect("openai.default entry should remain configured");
3139 assert!(
3140 !entry.requires_openai_auth,
3141 "requires_openai_auth must be cleared when --api-key flag is used on a Codex alias"
3142 );
3143 assert_eq!(
3144 entry.api_key.as_deref(),
3145 Some("sk-forced-key"),
3146 "forced api_key must be persisted"
3147 );
3148 }
3149
3150 #[tokio::test]
3151 async fn agent_alias_picker_preselects_stored_value() {
3152 use zeroclaw_config::schema::AliasedAgentConfig;
3153
3154 struct Capture {
3155 current: Option<usize>,
3156 items: Vec<String>,
3157 }
3158 #[async_trait::async_trait]
3159 impl OnboardUi for Capture {
3160 async fn confirm(&mut self, _: &str, _: bool) -> anyhow::Result<Answer<bool>> {
3161 Ok(Answer::Back)
3162 }
3163 async fn string(
3164 &mut self,
3165 _: &str,
3166 _: Option<&str>,
3167 _: Option<&str>,
3168 ) -> anyhow::Result<Answer<String>> {
3169 Ok(Answer::Back)
3170 }
3171 async fn secret(&mut self, _: &str, _: bool) -> anyhow::Result<Answer<Option<String>>> {
3172 Ok(Answer::Back)
3173 }
3174 async fn select(
3175 &mut self,
3176 _: &str,
3177 items: &[SelectItem],
3178 current: Option<usize>,
3179 ) -> anyhow::Result<Answer<usize>> {
3180 self.current = current;
3181 self.items = items.iter().map(|i| i.label.clone()).collect();
3182 Ok(Answer::Back)
3183 }
3184 async fn editor(&mut self, _: &str, _: &str) -> anyhow::Result<Answer<String>> {
3185 Ok(Answer::Back)
3186 }
3187 fn heading(&mut self, _: u8, _: &str) {}
3188 fn note(&mut self, _: &str) {}
3189 fn status(&mut self, _: &str) {}
3190 fn warn(&mut self, _: &str) {}
3191 }
3192
3193 for available in [
3194 vec!["clamps".to_string(), "glados".to_string()],
3195 vec!["glados".to_string(), "clamps".to_string()],
3196 ] {
3197 let temp = TempDir::new().unwrap();
3198 let mut cfg = test_cfg(&temp);
3199 cfg.agents.insert(
3200 "clamps".into(),
3201 AliasedAgentConfig {
3202 risk_profile: "clamps".into(),
3203 ..AliasedAgentConfig::default()
3204 },
3205 );
3206
3207 let mut ui = Capture {
3208 current: None,
3209 items: Vec::new(),
3210 };
3211 prompt_agent_alias_single(&mut cfg, &mut ui, "clamps", "risk_profile", &available)
3212 .await
3213 .unwrap();
3214
3215 let cursor = ui.current.expect("ui.select must receive a current index");
3216 let highlighted = ui.items.get(cursor).cloned().unwrap_or_default();
3217 assert_eq!(
3218 highlighted, "clamps",
3219 "available={available:?}, items={:?}, cursor={cursor} — \
3220 expected cursor on \"clamps\"",
3221 ui.items,
3222 );
3223 }
3224 }
3225}