Skip to main content

rho_coding_agent/app/
bootstrap.rs

1use std::{
2    io::{self, IsTerminal},
3    num::NonZeroUsize,
4    sync::Arc,
5    time::Duration,
6};
7
8use tracing::Instrument;
9
10use {
11    crate::cli::{Cli, Command, CredentialStoreCommand, OutputFormat},
12    crate::credential_store::AppCredentialStore,
13    crate::diagnostics::RuntimeDiagnostics,
14    crate::herdr::HerdrReporter,
15    crate::tui::SetupEntry,
16    crate::update,
17    rho_providers::model::ModelError,
18};
19
20use super::{
21    acp,
22    agent_binding::{AgentBinder, AgentInvocation, AgentRole},
23    automation, automation_protocol, cli_config,
24    config_repository::ConfigRepository,
25    interactive, login, mcp_cli, plugins_cli,
26    sdk_config::SdkBootstrapOptions,
27    sessions_cli, workflow_cli,
28};
29
30pub async fn run(cli: Cli) -> anyhow::Result<()> {
31    crate::logging::install_from_env();
32    if workflow_cli::planner_worker_requested(&cli) {
33        return workflow_cli::run_planner_worker().await;
34    }
35    let run_output = match &cli.command {
36        Some(Command::Run { output, .. }) => Some(*output),
37        _ => None,
38    };
39    let result = Box::pin(run_inner(cli).instrument(tracing::info_span!("startup"))).await;
40    let Err(error) = result else {
41        return Ok(());
42    };
43    if error.downcast_ref::<automation::AutomationExit>().is_some()
44        || error
45            .downcast_ref::<automation::AutomationInterrupted>()
46            .is_some()
47    {
48        return Err(error);
49    }
50    if run_output == Some(OutputFormat::Jsonl) {
51        let message = error.to_string();
52        automation::emit_startup_failure(message.clone())?;
53        return Err(automation::AutomationExit::new(
54            2,
55            automation_protocol::TerminalReason::ConfigurationError,
56            message,
57        )
58        .into());
59    }
60    if run_output.is_some() {
61        return Err(automation::AutomationExit::new(
62            2,
63            automation_protocol::TerminalReason::ConfigurationError,
64            error.to_string(),
65        )
66        .into());
67    }
68    Err(error)
69}
70
71async fn run_inner(cli: Cli) -> anyhow::Result<()> {
72    cli_config::validate(&cli)?;
73    if let EarlyDispatch::Handled(result) = dispatch_early_command(&cli).await? {
74        return result;
75    }
76
77    let PreparedStartup {
78        cli,
79        catalog,
80        mut config,
81        config_repository,
82        first_run,
83        cwd,
84        automation_prompt,
85        output_file,
86        output,
87        max_steps,
88        timeout,
89        bound_agent,
90        bound_reasoning_source,
91        provider_refresh,
92        store,
93    } = prepare_startup(cli).await?;
94
95    validate_terminal_mode(&cli)?;
96    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
97    cli_config::normalize_reasoning_for_cli(&mut config, bound_reasoning_source)?;
98    let herdr = HerdrReporter::from_env();
99    if let Some(prompt) = automation_prompt {
100        return run_automation_startup(AutomationStartup {
101            prompt,
102            config: &config,
103            config_repository: &config_repository,
104            cwd,
105            cli: &cli,
106            bound_agent,
107            output_file,
108            output,
109            max_steps,
110            timeout,
111            herdr,
112        })
113        .await;
114    }
115    if matches!(cli.command, Some(Command::Acp)) {
116        return run_acp_startup(AcpCommandStartup {
117            config,
118            config_repository,
119            cwd,
120            cli,
121            bound_agent,
122            herdr,
123        })
124        .await;
125    }
126    run_interactive_startup(InteractiveStartup {
127        cli: &cli,
128        catalog,
129        config,
130        config_repository,
131        first_run,
132        cwd,
133        bound_agent,
134        bound_reasoning_source,
135        herdr,
136    })
137    .await
138}
139
140enum EarlyDispatch {
141    Handled(anyhow::Result<()>),
142    Continue,
143}
144
145async fn dispatch_early_command(cli: &Cli) -> anyhow::Result<EarlyDispatch> {
146    if let Some(Command::Workflow { command }) = &cli.command {
147        return Ok(EarlyDispatch::Handled(
148            workflow_cli::run(command, cli).await,
149        ));
150    }
151    if let Some(Command::CredentialStore { command }) = &cli.command {
152        return Ok(EarlyDispatch::Handled(run_credential_store_command(
153            command,
154            cli.config.clone(),
155        )));
156    }
157    if let Some(Command::Sessions { command }) = &cli.command {
158        return Ok(EarlyDispatch::Handled(sessions_cli::run(command)));
159    }
160    if let Some(Command::Mcp { command }) = &cli.command {
161        return Ok(EarlyDispatch::Handled(mcp_cli::run(command, cli).await));
162    }
163    if let Some(Command::Plugins { command }) = &cli.command {
164        return Ok(EarlyDispatch::Handled(plugins_cli::run(command, cli)));
165    }
166    if let Some(Command::Attach { id }) = &cli.command {
167        // Attach is early-dispatched, so load display settings here the way the
168        // interactive TUI gets them through RuntimeModelView. Propagate load
169        // failures instead of failing open to full reasoning / work chrome.
170        let config = ConfigRepository::new(cli.config.clone()).load()?;
171        let display = crate::tui::AttachmentDisplaySettings::from_config(&config);
172        return Ok(EarlyDispatch::Handled(
173            crate::tui::run_attachment(
174                id.as_deref(),
175                display,
176                &config.theme,
177                HerdrReporter::from_env(),
178            )
179            .await,
180        ));
181    }
182    if matches!(cli.command, Some(Command::Update)) {
183        return Ok(EarlyDispatch::Handled(
184            update::run_update(env!("CARGO_PKG_VERSION")).await,
185        ));
186    }
187    if let Some(Command::Login {
188        provider,
189        device_auth,
190    }) = &cli.command
191    {
192        let config_repository = ConfigRepository::new(cli.config.clone());
193        let mut config = config_repository.load()?;
194        let config_path = absolute_config_path(&config_repository)?;
195        ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
196        crate::credential_store::initialize_from_config(&mut config, &config_path)?;
197        return Ok(EarlyDispatch::Handled(
198            login::run(provider, *device_auth).await,
199        ));
200    }
201    Ok(EarlyDispatch::Continue)
202}
203
204struct PreparedStartup {
205    cli: Cli,
206    catalog: crate::agent::DiscoveredAgentCatalog,
207    config: crate::config::Config,
208    config_repository: ConfigRepository,
209    first_run: Option<SetupEntry>,
210    cwd: std::path::PathBuf,
211    automation_prompt: Option<String>,
212    output_file: Option<std::path::PathBuf>,
213    output: OutputFormat,
214    max_steps: Option<NonZeroUsize>,
215    timeout: Option<Duration>,
216    bound_agent: super::agent_binding::BoundAgent,
217    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
218    provider_refresh: cli_config::ProviderRefreshStatus,
219    store: AppCredentialStore,
220}
221
222async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
223    let config_path = cli.config.clone();
224    let config_repository = ConfigRepository::new(config_path.clone());
225    // Ask before loading; loading writes the default config when none exists.
226    let first_run = detect_first_run(&config_repository);
227    let mut config = config_repository.load()?;
228    // Register every [providers.custom.*] name before refresh, pickers, and /model.
229    config.providers.activate()?;
230    let absolute_config = absolute_config_path(&config_repository)?;
231    crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
232    let cwd = std::env::current_dir()?;
233    let automation_prompt = automation::prompt_for_command(&cli.command)?;
234    let (output_file, output, max_steps, timeout) = match &cli.command {
235        Some(Command::Run {
236            output_file,
237            output,
238            max_steps,
239            timeout,
240            ..
241        }) => (output_file.clone(), *output, *max_steps, *timeout),
242        _ => (None, OutputFormat::Text, None, None),
243    };
244    let catalog = Arc::new(crate::agent::AgentCatalog::discover(&cwd)?);
245    let selected_agent = cli.agent.as_deref().unwrap_or("default");
246    let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
247    // The walk is reused for the delegation tool set so startup discovers once.
248    let catalog = crate::agent::DiscoveredAgentCatalog::new(cwd.clone(), catalog);
249
250    // Only automation, ACP, and interactive sessions reach here; every other
251    // command dispatches early.
252    let role = if automation_prompt.is_some() || matches!(cli.command, Some(Command::Acp)) {
253        AgentRole::AutomationRoot
254    } else {
255        AgentRole::InteractiveRoot
256    };
257
258    let store = AppCredentialStore;
259    // Interactive sessions refresh custom-provider models after the first frame
260    // so a slow host cannot hold up paint. Automation and ACP resolve the model
261    // list up front, because they get one shot to pick a model.
262    if matches!(role, AgentRole::AutomationRoot) {
263        cli_config::refresh_custom_provider_models(&config, &store).await;
264    }
265    let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
266    let permission_mode_before_override = config.permission_mode;
267    let config_changed = cli_config::apply_overrides(&mut config, &cli)?;
268    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
269    // Full models.dev snapshot fills in the background for subagent and status
270    // labels. Interactive sessions stay cache-only on the first frame, then
271    // rewrite the startup prompt once if hydrate lands before the first request.
272    tokio::spawn(rho_providers::model::models_dev::ensure_models_dev_catalog());
273    cli_config::normalize_reasoning_for_cli(
274        &mut config,
275        if cli.reasoning.is_some() {
276            rho_providers::model::ReasoningRequestSource::Explicit
277        } else {
278            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
279        },
280    )?;
281    // CLI overrides are session-only unless the user passes --save and an
282    // override actually changed the selection. Auto-saving rewrote the whole
283    // file and dropped comments. Bare --save, no-op identical overrides, and
284    // reasoning auto-normalization alone must not rewrite config.
285    if cli.save && config_changed {
286        let session_permission_mode = config.permission_mode;
287        config.permission_mode = permission_mode_before_override;
288        config_repository.save(&config)?;
289        config.permission_mode = session_permission_mode;
290    }
291    let reasoning_before_binding = config.reasoning;
292    let bound_agent = AgentBinder::bind(
293        definition,
294        AgentInvocation {
295            role,
296            available_tools: host_capabilities(&cli, &config, role),
297        },
298        &config,
299    )?;
300    config = bound_agent.rho_config().cloned().unwrap_or(config);
301    let bound_reasoning_source =
302        if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
303            rho_providers::model::ReasoningRequestSource::Explicit
304        } else {
305            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
306        };
307
308    Ok(PreparedStartup {
309        cli,
310        catalog,
311        config,
312        config_repository,
313        first_run,
314        cwd,
315        automation_prompt,
316        output_file,
317        output,
318        max_steps,
319        timeout,
320        bound_agent,
321        bound_reasoning_source,
322        provider_refresh,
323        store,
324    })
325}
326
327struct AutomationStartup<'a> {
328    prompt: String,
329    config: &'a crate::config::Config,
330    config_repository: &'a ConfigRepository,
331    cwd: std::path::PathBuf,
332    cli: &'a Cli,
333    bound_agent: super::agent_binding::BoundAgent,
334    output_file: Option<std::path::PathBuf>,
335    output: OutputFormat,
336    max_steps: Option<NonZeroUsize>,
337    timeout: Option<Duration>,
338    herdr: HerdrReporter,
339}
340
341async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
342    let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
343    automation::run(
344        startup.prompt,
345        automation::Startup {
346            config: startup.config,
347            config_path: absolute_config_path(startup.config_repository)?,
348            cwd: startup.cwd,
349            no_system_prompt: startup.cli.no_system_prompt,
350            no_tools: startup.cli.no_tools,
351            no_subagents: startup.cli.no_subagents,
352            usage_purpose: "agent",
353            parent_session_id: None,
354            agent: startup.bound_agent,
355            output_file: startup.output_file,
356            output: startup.output,
357            max_steps: startup.max_steps,
358            timeout: startup.timeout,
359            diagnostics,
360            herdr: startup.herdr,
361            host_input: None,
362            notice_poster: None,
363            steering_slot: None,
364            approval_session: None,
365            approval_classifier: None,
366            hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
367        },
368    )
369    .await
370}
371
372struct AcpCommandStartup {
373    config: crate::config::Config,
374    config_repository: ConfigRepository,
375    cwd: std::path::PathBuf,
376    cli: Cli,
377    bound_agent: super::agent_binding::BoundAgent,
378    herdr: HerdrReporter,
379}
380
381async fn run_acp_startup(startup: AcpCommandStartup) -> anyhow::Result<()> {
382    let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
383    acp::run(acp::AcpStartup {
384        config: startup.config,
385        config_path: absolute_config_path(&startup.config_repository)?,
386        cwd: startup.cwd,
387        no_system_prompt: startup.cli.no_system_prompt,
388        no_tools: startup.cli.no_tools,
389        no_subagents: startup.cli.no_subagents,
390        agent: startup.bound_agent,
391        diagnostics,
392        herdr: startup.herdr,
393    })
394    .await
395}
396
397struct InteractiveStartup<'a> {
398    cli: &'a Cli,
399    catalog: crate::agent::DiscoveredAgentCatalog,
400    config: crate::config::Config,
401    config_repository: ConfigRepository,
402    first_run: Option<SetupEntry>,
403    cwd: std::path::PathBuf,
404    bound_agent: super::agent_binding::BoundAgent,
405    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
406    herdr: HerdrReporter,
407}
408
409async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
410    let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
411
412    let pending_update_notice = startup
413        .config
414        .check_for_updates
415        .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
416    let pending_custom_models = (!startup.config.providers.custom.is_empty()).then(|| {
417        let config = startup.config.clone();
418        tokio::spawn(
419            async move {
420                cli_config::refresh_custom_provider_models(&config, &AppCredentialStore).await;
421            }
422            .instrument(tracing::info_span!("startup.custom_models")),
423        )
424    });
425    let prompt_history_limit = startup.config.prompt_history_limit;
426    let pending_prompt_history = (prompt_history_limit > 0).then(|| {
427        tokio::spawn(
428            async move {
429                tokio::task::spawn_blocking(move || {
430                    match crate::prompt_history::PromptHistoryStore::at_default_path() {
431                        Ok(store) => match store.load_tail(prompt_history_limit) {
432                            Ok(tail) => Some((store, tail)),
433                            Err(error) => {
434                                tracing::warn!(%error, "failed to load prompt history");
435                                None
436                            }
437                        },
438                        Err(error) => {
439                            tracing::warn!(%error, "failed to open prompt history");
440                            None
441                        }
442                    }
443                })
444                .await
445                .unwrap_or_else(|error| {
446                    tracing::warn!(%error, "prompt history task failed");
447                    None
448                })
449            }
450            .instrument(tracing::info_span!("startup.prompt_history")),
451        )
452    });
453
454    // No thread overlay here: `prepare_startup` already installed these hosts
455    // process-wide. An overlay would additionally hide later `/login` additions
456    // from every spawned task, since thread-locals do not follow `tokio::spawn`.
457    let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
458    let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
459        Arc::new(AppCredentialStore),
460    );
461    let provider_result = rho_providers::providers::build_sdk_provider_with_source(
462        sdk_options.provider,
463        &credentials,
464    );
465    let (missing_auth_error, missing_auth_model_error) = match provider_result {
466        Ok(_) => (None, None),
467        Err(error) if is_interactive_startup_unavailable_error(&error) => {
468            (Some(error.to_string()), Some(error))
469        }
470        Err(error) => return Err(error.into()),
471    };
472    interactive::run(interactive::Startup {
473        cli: startup.cli,
474        catalog: startup.catalog,
475        config: startup.config,
476        config_path: absolute_config_path(&startup.config_repository)?,
477        config_repository: startup.config_repository,
478        cwd: startup.cwd,
479        first_run: startup.first_run,
480        missing_auth_error,
481        missing_auth_model_error,
482        pending_update_notice,
483        pending_custom_models,
484        pending_prompt_history,
485        diagnostics,
486        herdr: startup.herdr,
487        agent: startup.bound_agent,
488        reasoning_source: startup.bound_reasoning_source,
489    })
490    .await
491}
492
493fn bind_agent_diagnostics(
494    config: &crate::config::Config,
495    agent: &super::agent_binding::BoundAgent,
496) -> RuntimeDiagnostics {
497    let diagnostics = RuntimeDiagnostics::new(config);
498    diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
499    diagnostics
500}
501
502fn ensure_cli_credential_store_choice(
503    config: &mut crate::config::Config,
504    config_path: Option<std::path::PathBuf>,
505) -> anyhow::Result<()> {
506    use rho_providers::credentials::CredentialStoreBackend;
507    use std::io::{self, IsTerminal, Write};
508
509    let Some(request) = crate::credential_store::choice_request(config) else {
510        return Ok(());
511    };
512
513    if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
514        anyhow::bail!(
515            "credential store is unset; set it before non-interactive login with \
516`rho credential-store set os|file`, behavior.credential_store in config.toml, \
517or RHO_CREDENTIAL_STORE=os|file"
518        );
519    }
520
521    let backends = request.available_backends();
522    if backends.is_empty() {
523        anyhow::bail!(
524            "no credential store backend is available (os: {}; file: {})",
525            request.os.detail,
526            request.file.detail
527        );
528    }
529
530    eprintln!("Choose where Rho stores provider credentials:");
531    eprintln!("This is saved to config and used for future logins on this machine.");
532    if request.os.available {
533        eprintln!("  [1] OS credential store (recommended)");
534    } else {
535        eprintln!(
536            "  [1] OS credential store (unavailable: {})",
537            request.os.detail
538        );
539    }
540    if request.file.available {
541        eprintln!("  [2] Local file under ~/.rho/credentials (not encrypted at rest)");
542    } else {
543        eprintln!("  [2] Local file (unavailable: {})", request.file.detail);
544    }
545    let default_backend = request
546        .default_backend()
547        .unwrap_or(CredentialStoreBackend::Os);
548    let default_hint = match default_backend {
549        CredentialStoreBackend::Os => "1",
550        CredentialStoreBackend::File => "2",
551    };
552    eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
553    io::stderr().flush()?;
554
555    let mut answer = String::new();
556    io::stdin().read_line(&mut answer)?;
557    let backend = match answer.trim() {
558        "" => default_backend,
559        "1" | "os" | "OS" => CredentialStoreBackend::Os,
560        "2" | "file" | "FILE" => CredentialStoreBackend::File,
561        other => {
562            anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
563        }
564    };
565    if !backends.contains(&backend) {
566        let detail = request.detail_for(backend);
567        anyhow::bail!(
568            "{} credential store is unavailable: {detail}",
569            backend.as_str()
570        );
571    }
572
573    let path = crate::credential_store::set_backend(backend, config_path)?;
574    config.credential_store = Some(backend);
575    eprintln!(
576        "credential store set to {} in {}",
577        backend.as_str(),
578        path.display()
579    );
580    Ok(())
581}
582
583fn run_credential_store_command(
584    command: &CredentialStoreCommand,
585    config_path: Option<std::path::PathBuf>,
586) -> anyhow::Result<()> {
587    match command {
588        CredentialStoreCommand::Probe { backend } => {
589            let result = crate::credential_store::probe(*backend);
590            if result.available {
591                println!("available: {}", result.detail);
592                Ok(())
593            } else {
594                anyhow::bail!(result.detail)
595            }
596        }
597        CredentialStoreCommand::Status => {
598            // Saved config policy only (ignore RHO_CREDENTIAL_STORE).
599            match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
600                None => println!("unset"),
601                Some(backend) => println!("{}", backend.as_str()),
602            }
603            Ok(())
604        }
605        CredentialStoreCommand::Set { backend } => {
606            let path = crate::credential_store::set_backend(*backend, config_path)?;
607            println!(
608                "credential store set to {} in {}",
609                backend.as_str(),
610                path.display()
611            );
612            Ok(())
613        }
614    }
615}
616
617pub(super) fn host_capabilities(
618    cli: &Cli,
619    config: &crate::config::Config,
620    role: AgentRole,
621) -> crate::agent::AgentCapabilities {
622    use crate::agent::ToolCapability;
623
624    if cli.no_tools {
625        return crate::agent::AgentCapabilities::default();
626    }
627    let mut tools = crate::agent::AgentCapabilities::all_host_tools();
628    // web_search is gated after bind against the resolved provider/model.
629    #[cfg(windows)]
630    tools.remove(&ToolCapability::Bash);
631    #[cfg(not(windows))]
632    tools.remove(&ToolCapability::Powershell);
633    if cli.no_subagents || !config.enable_subagents {
634        tools.remove(&ToolCapability::Agent);
635        tools.remove(&ToolCapability::Agents);
636    }
637    if role != AgentRole::InteractiveRoot {
638        tools.remove(&ToolCapability::Questionnaire);
639    }
640    #[cfg(debug_assertions)]
641    if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
642        tools.insert(ToolCapability::Extension(
643            crate::tools::tui_fixture::NAME.into(),
644        ));
645    }
646    tools
647}
648
649pub(super) fn absolute_config_path(
650    repository: &ConfigRepository,
651) -> anyhow::Result<std::path::PathBuf> {
652    let path = repository.configured_path()?;
653    if path.is_absolute() {
654        Ok(path)
655    } else {
656        Ok(std::env::current_dir()?.join(path))
657    }
658}
659
660/// Opens the first-run setup screen so the flow can be reviewed without
661/// deleting a working config. See [`parse_first_run_override`] for the values.
662const FIRST_RUN_OVERRIDE_VAR: &str = "RHO_FIRST_RUN";
663
664/// Which step the override asks for, or `None` when it does not ask at all.
665///
666/// `signin` and `model` name a step, because a configured machine already has
667/// models and would otherwise always land on the model step, leaving the
668/// provider menu unreachable. Any other non-empty value that is not `0` means
669/// "open setup", letting the step be chosen the way a real first launch does.
670fn parse_first_run_override(value: &str) -> Option<SetupEntry> {
671    match value.trim().to_ascii_lowercase().as_str() {
672        "" | "0" | "false" | "no" => None,
673        "signin" | "sign-in" | "login" => Some(SetupEntry::SignIn),
674        "model" | "models" => Some(SetupEntry::ChooseModel),
675        _ => Some(SetupEntry::Auto),
676    }
677}
678
679/// The setup entry for this launch: the override when it is set, otherwise
680/// [`SetupEntry::Auto`] when Rho is about to create the config file.
681///
682/// Call this before loading the config, because loading writes the defaults.
683fn detect_first_run(repository: &ConfigRepository) -> Option<SetupEntry> {
684    if let Ok(value) = std::env::var(FIRST_RUN_OVERRIDE_VAR) {
685        if let Some(entry) = parse_first_run_override(&value) {
686            return Some(entry);
687        }
688    }
689    repository
690        .configured_path()
691        .is_ok_and(|path| !path.exists())
692        .then_some(SetupEntry::Auto)
693}
694
695fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
696    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
697        anyhow::bail!(
698            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
699        );
700    }
701    Ok(())
702}
703
704fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
705    matches!(
706        error,
707        ModelError::MissingCredentials(_)
708            | ModelError::Credentials(_)
709            | ModelError::UnsupportedProvider(_)
710    )
711}
712
713#[cfg(test)]
714#[path = "bootstrap_tests.rs"]
715mod tests;