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 display = crate::tui::AttachmentDisplaySettings::from_config(
171            &ConfigRepository::new(cli.config.clone()).load()?,
172        );
173        return Ok(EarlyDispatch::Handled(
174            crate::tui::run_attachment(id.as_deref(), display, HerdrReporter::from_env()).await,
175        ));
176    }
177    if matches!(cli.command, Some(Command::Update)) {
178        return Ok(EarlyDispatch::Handled(
179            update::run_update(env!("CARGO_PKG_VERSION")).await,
180        ));
181    }
182    if let Some(Command::Login {
183        provider,
184        device_auth,
185    }) = &cli.command
186    {
187        let config_repository = ConfigRepository::new(cli.config.clone());
188        let mut config = config_repository.load()?;
189        let config_path = absolute_config_path(&config_repository)?;
190        ensure_cli_credential_store_choice(&mut config, Some(config_path.clone()))?;
191        crate::credential_store::initialize_from_config(&mut config, &config_path)?;
192        return Ok(EarlyDispatch::Handled(
193            login::run(provider, *device_auth).await,
194        ));
195    }
196    Ok(EarlyDispatch::Continue)
197}
198
199struct PreparedStartup {
200    cli: Cli,
201    catalog: crate::agent::DiscoveredAgentCatalog,
202    config: crate::config::Config,
203    config_repository: ConfigRepository,
204    first_run: Option<SetupEntry>,
205    cwd: std::path::PathBuf,
206    automation_prompt: Option<String>,
207    output_file: Option<std::path::PathBuf>,
208    output: OutputFormat,
209    max_steps: Option<NonZeroUsize>,
210    timeout: Option<Duration>,
211    bound_agent: super::agent_binding::BoundAgent,
212    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
213    provider_refresh: cli_config::ProviderRefreshStatus,
214    store: AppCredentialStore,
215}
216
217async fn prepare_startup(cli: Cli) -> anyhow::Result<PreparedStartup> {
218    let config_path = cli.config.clone();
219    let config_repository = ConfigRepository::new(config_path.clone());
220    // Ask before loading; loading writes the default config when none exists.
221    let first_run = detect_first_run(&config_repository);
222    let mut config = config_repository.load()?;
223    // Register every [providers.custom.*] name before refresh, pickers, and /model.
224    config.providers.activate()?;
225    let absolute_config = absolute_config_path(&config_repository)?;
226    crate::credential_store::initialize_from_config(&mut config, &absolute_config)?;
227    let cwd = std::env::current_dir()?;
228    let automation_prompt = automation::prompt_for_command(&cli.command)?;
229    let (output_file, output, max_steps, timeout) = match &cli.command {
230        Some(Command::Run {
231            output_file,
232            output,
233            max_steps,
234            timeout,
235            ..
236        }) => (output_file.clone(), *output, *max_steps, *timeout),
237        _ => (None, OutputFormat::Text, None, None),
238    };
239    let catalog = Arc::new(crate::agent::AgentCatalog::discover(&cwd)?);
240    let selected_agent = cli.agent.as_deref().unwrap_or("default");
241    let definition = Arc::new(catalog.find(selected_agent)?.definition.clone());
242    // The walk is reused for the delegation tool set so startup discovers once.
243    let catalog = crate::agent::DiscoveredAgentCatalog::new(cwd.clone(), catalog);
244
245    // Only automation, ACP, and interactive sessions reach here; every other
246    // command dispatches early.
247    let role = if automation_prompt.is_some() || matches!(cli.command, Some(Command::Acp)) {
248        AgentRole::AutomationRoot
249    } else {
250        AgentRole::InteractiveRoot
251    };
252
253    let store = AppCredentialStore;
254    // Interactive sessions refresh custom-provider models after the first frame
255    // so a slow host cannot hold up paint. Automation and ACP resolve the model
256    // list up front, because they get one shot to pick a model.
257    if matches!(role, AgentRole::AutomationRoot) {
258        cli_config::refresh_custom_provider_models(&config, &store).await;
259    }
260    let provider_refresh = cli_config::refresh_model_cache(&cli, &config, &store).await?;
261    let permission_mode_before_override = config.permission_mode;
262    let config_changed = cli_config::apply_overrides(&mut config, &cli)?;
263    cli_config::prepare_model_metadata(&config, &store, &provider_refresh).await;
264    // Full models.dev snapshot fills in the background for subagent and status
265    // labels. Interactive sessions stay cache-only on the first frame, then
266    // rewrite the startup prompt once if hydrate lands before the first request.
267    tokio::spawn(rho_providers::model::models_dev::ensure_models_dev_catalog());
268    cli_config::normalize_reasoning_for_cli(
269        &mut config,
270        if cli.reasoning.is_some() {
271            rho_providers::model::ReasoningRequestSource::Explicit
272        } else {
273            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
274        },
275    )?;
276    // CLI overrides are session-only unless the user passes --save and an
277    // override actually changed the selection. Auto-saving rewrote the whole
278    // file and dropped comments. Bare --save, no-op identical overrides, and
279    // reasoning auto-normalization alone must not rewrite config.
280    if cli.save && config_changed {
281        let session_permission_mode = config.permission_mode;
282        config.permission_mode = permission_mode_before_override;
283        config_repository.save(&config)?;
284        config.permission_mode = session_permission_mode;
285    }
286    let reasoning_before_binding = config.reasoning;
287    let bound_agent = AgentBinder::bind(
288        definition,
289        AgentInvocation {
290            role,
291            available_tools: host_capabilities(&cli, &config, role),
292        },
293        &config,
294    )?;
295    config = bound_agent.rho_config().cloned().unwrap_or(config);
296    let bound_reasoning_source =
297        if cli.reasoning.is_some() && config.reasoning == reasoning_before_binding {
298            rho_providers::model::ReasoningRequestSource::Explicit
299        } else {
300            rho_providers::model::ReasoningRequestSource::PersistedOrDefault
301        };
302
303    Ok(PreparedStartup {
304        cli,
305        catalog,
306        config,
307        config_repository,
308        first_run,
309        cwd,
310        automation_prompt,
311        output_file,
312        output,
313        max_steps,
314        timeout,
315        bound_agent,
316        bound_reasoning_source,
317        provider_refresh,
318        store,
319    })
320}
321
322struct AutomationStartup<'a> {
323    prompt: String,
324    config: &'a crate::config::Config,
325    config_repository: &'a ConfigRepository,
326    cwd: std::path::PathBuf,
327    cli: &'a Cli,
328    bound_agent: super::agent_binding::BoundAgent,
329    output_file: Option<std::path::PathBuf>,
330    output: OutputFormat,
331    max_steps: Option<NonZeroUsize>,
332    timeout: Option<Duration>,
333    herdr: HerdrReporter,
334}
335
336async fn run_automation_startup(startup: AutomationStartup<'_>) -> anyhow::Result<()> {
337    let diagnostics = bind_agent_diagnostics(startup.config, &startup.bound_agent);
338    automation::run(
339        startup.prompt,
340        automation::Startup {
341            config: startup.config,
342            config_path: absolute_config_path(startup.config_repository)?,
343            cwd: startup.cwd,
344            no_system_prompt: startup.cli.no_system_prompt,
345            no_tools: startup.cli.no_tools,
346            no_subagents: startup.cli.no_subagents,
347            usage_purpose: "agent",
348            parent_session_id: None,
349            agent: startup.bound_agent,
350            output_file: startup.output_file,
351            output: startup.output,
352            max_steps: startup.max_steps,
353            timeout: startup.timeout,
354            diagnostics,
355            herdr: startup.herdr,
356            host_input: None,
357            notice_poster: None,
358            steering_slot: None,
359            approval_session: None,
360            approval_classifier: None,
361            hook_host_labels: rho_sdk::hooks::HookHostLabels::new(),
362        },
363    )
364    .await
365}
366
367struct AcpCommandStartup {
368    config: crate::config::Config,
369    config_repository: ConfigRepository,
370    cwd: std::path::PathBuf,
371    cli: Cli,
372    bound_agent: super::agent_binding::BoundAgent,
373    herdr: HerdrReporter,
374}
375
376async fn run_acp_startup(startup: AcpCommandStartup) -> anyhow::Result<()> {
377    let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
378    acp::run(acp::AcpStartup {
379        config: startup.config,
380        config_path: absolute_config_path(&startup.config_repository)?,
381        cwd: startup.cwd,
382        no_system_prompt: startup.cli.no_system_prompt,
383        no_tools: startup.cli.no_tools,
384        no_subagents: startup.cli.no_subagents,
385        agent: startup.bound_agent,
386        diagnostics,
387        herdr: startup.herdr,
388    })
389    .await
390}
391
392struct InteractiveStartup<'a> {
393    cli: &'a Cli,
394    catalog: crate::agent::DiscoveredAgentCatalog,
395    config: crate::config::Config,
396    config_repository: ConfigRepository,
397    first_run: Option<SetupEntry>,
398    cwd: std::path::PathBuf,
399    bound_agent: super::agent_binding::BoundAgent,
400    bound_reasoning_source: rho_providers::model::ReasoningRequestSource,
401    herdr: HerdrReporter,
402}
403
404async fn run_interactive_startup(startup: InteractiveStartup<'_>) -> anyhow::Result<()> {
405    let diagnostics = bind_agent_diagnostics(&startup.config, &startup.bound_agent);
406
407    let pending_update_notice = startup
408        .config
409        .check_for_updates
410        .then(|| tokio::spawn(update::update_notice(env!("CARGO_PKG_VERSION"))));
411    let pending_custom_models = (!startup.config.providers.custom.is_empty()).then(|| {
412        let config = startup.config.clone();
413        tokio::spawn(
414            async move {
415                cli_config::refresh_custom_provider_models(&config, &AppCredentialStore).await;
416            }
417            .instrument(tracing::info_span!("startup.custom_models")),
418        )
419    });
420
421    let _scope = startup.config.providers.thread_scope()?;
422    let sdk_options = SdkBootstrapOptions::from_config(&startup.config, &startup.cwd)?;
423    let credentials = rho_providers::auth::provider_credentials::ApplicationCredentialSource::new(
424        Arc::new(AppCredentialStore),
425    );
426    let provider_result = rho_providers::providers::build_sdk_provider_with_source(
427        sdk_options.provider,
428        &credentials,
429    );
430    let (missing_auth_error, missing_auth_model_error) = match provider_result {
431        Ok(_) => (None, None),
432        Err(error) if is_interactive_startup_unavailable_error(&error) => {
433            (Some(error.to_string()), Some(error))
434        }
435        Err(error) => return Err(error.into()),
436    };
437    interactive::run(interactive::Startup {
438        cli: startup.cli,
439        catalog: startup.catalog,
440        config: startup.config,
441        config_path: absolute_config_path(&startup.config_repository)?,
442        config_repository: startup.config_repository,
443        cwd: startup.cwd,
444        first_run: startup.first_run,
445        missing_auth_error,
446        missing_auth_model_error,
447        pending_update_notice,
448        pending_custom_models,
449        diagnostics,
450        herdr: startup.herdr,
451        agent: startup.bound_agent,
452        reasoning_source: startup.bound_reasoning_source,
453    })
454    .await
455}
456
457fn bind_agent_diagnostics(
458    config: &crate::config::Config,
459    agent: &super::agent_binding::BoundAgent,
460) -> RuntimeDiagnostics {
461    let diagnostics = RuntimeDiagnostics::new(config);
462    diagnostics.update_agent(agent.id().as_str(), &agent.fingerprint().to_string());
463    diagnostics
464}
465
466fn ensure_cli_credential_store_choice(
467    config: &mut crate::config::Config,
468    config_path: Option<std::path::PathBuf>,
469) -> anyhow::Result<()> {
470    use rho_providers::credentials::CredentialStoreBackend;
471    use std::io::{self, IsTerminal, Write};
472
473    let Some(request) = crate::credential_store::choice_request(config) else {
474        return Ok(());
475    };
476
477    if !io::stdin().is_terminal() || !io::stderr().is_terminal() {
478        anyhow::bail!(
479            "credential store is unset; set it before non-interactive login with \
480`rho credential-store set os|file`, behavior.credential_store in config.toml, \
481or RHO_CREDENTIAL_STORE=os|file"
482        );
483    }
484
485    let backends = request.available_backends();
486    if backends.is_empty() {
487        anyhow::bail!(
488            "no credential store backend is available (os: {}; file: {})",
489            request.os.detail,
490            request.file.detail
491        );
492    }
493
494    eprintln!("Choose where Rho stores provider credentials:");
495    eprintln!("This is saved to config and used for future logins on this machine.");
496    if request.os.available {
497        eprintln!("  [1] OS credential store (recommended)");
498    } else {
499        eprintln!(
500            "  [1] OS credential store (unavailable: {})",
501            request.os.detail
502        );
503    }
504    if request.file.available {
505        eprintln!("  [2] Local file under ~/.rho/credentials (not encrypted at rest)");
506    } else {
507        eprintln!("  [2] Local file (unavailable: {})", request.file.detail);
508    }
509    let default_backend = request
510        .default_backend()
511        .unwrap_or(CredentialStoreBackend::Os);
512    let default_hint = match default_backend {
513        CredentialStoreBackend::Os => "1",
514        CredentialStoreBackend::File => "2",
515    };
516    eprint!("Choice [1/2 or os/file] (default {default_hint}): ");
517    io::stderr().flush()?;
518
519    let mut answer = String::new();
520    io::stdin().read_line(&mut answer)?;
521    let backend = match answer.trim() {
522        "" => default_backend,
523        "1" | "os" | "OS" => CredentialStoreBackend::Os,
524        "2" | "file" | "FILE" => CredentialStoreBackend::File,
525        other => {
526            anyhow::bail!("unrecognized credential store choice '{other}'; expected 1/os or 2/file")
527        }
528    };
529    if !backends.contains(&backend) {
530        let detail = request.detail_for(backend);
531        anyhow::bail!(
532            "{} credential store is unavailable: {detail}",
533            backend.as_str()
534        );
535    }
536
537    let path = crate::credential_store::set_backend(backend, config_path)?;
538    config.credential_store = Some(backend);
539    eprintln!(
540        "credential store set to {} in {}",
541        backend.as_str(),
542        path.display()
543    );
544    Ok(())
545}
546
547fn run_credential_store_command(
548    command: &CredentialStoreCommand,
549    config_path: Option<std::path::PathBuf>,
550) -> anyhow::Result<()> {
551    match command {
552        CredentialStoreCommand::Probe { backend } => {
553            let result = crate::credential_store::probe(*backend);
554            if result.available {
555                println!("available: {}", result.detail);
556                Ok(())
557            } else {
558                anyhow::bail!(result.detail)
559            }
560        }
561        CredentialStoreCommand::Status => {
562            // Saved config policy only (ignore RHO_CREDENTIAL_STORE).
563            match crate::credential_store::saved_policy_backend(config_path.as_deref())? {
564                None => println!("unset"),
565                Some(backend) => println!("{}", backend.as_str()),
566            }
567            Ok(())
568        }
569        CredentialStoreCommand::Set { backend } => {
570            let path = crate::credential_store::set_backend(*backend, config_path)?;
571            println!(
572                "credential store set to {} in {}",
573                backend.as_str(),
574                path.display()
575            );
576            Ok(())
577        }
578    }
579}
580
581pub(super) fn host_capabilities(
582    cli: &Cli,
583    config: &crate::config::Config,
584    role: AgentRole,
585) -> crate::agent::AgentCapabilities {
586    use crate::agent::ToolCapability;
587
588    if cli.no_tools {
589        return crate::agent::AgentCapabilities::default();
590    }
591    let mut tools = crate::agent::AgentCapabilities::all_host_tools();
592    // web_search is gated after bind against the resolved provider/model.
593    #[cfg(windows)]
594    tools.remove(&ToolCapability::Bash);
595    #[cfg(not(windows))]
596    tools.remove(&ToolCapability::Powershell);
597    if cli.no_subagents || !config.enable_subagents {
598        tools.remove(&ToolCapability::Agent);
599        tools.remove(&ToolCapability::Agents);
600    }
601    if role != AgentRole::InteractiveRoot {
602        tools.remove(&ToolCapability::Questionnaire);
603    }
604    #[cfg(debug_assertions)]
605    if std::env::var_os("RHO_TUI_TEST_MODE").as_deref() == Some(std::ffi::OsStr::new("matrix")) {
606        tools.insert(ToolCapability::Extension(
607            crate::tools::tui_fixture::NAME.into(),
608        ));
609    }
610    tools
611}
612
613pub(super) fn absolute_config_path(
614    repository: &ConfigRepository,
615) -> anyhow::Result<std::path::PathBuf> {
616    let path = repository.configured_path()?;
617    if path.is_absolute() {
618        Ok(path)
619    } else {
620        Ok(std::env::current_dir()?.join(path))
621    }
622}
623
624/// Opens the first-run setup screen so the flow can be reviewed without
625/// deleting a working config. See [`parse_first_run_override`] for the values.
626const FIRST_RUN_OVERRIDE_VAR: &str = "RHO_FIRST_RUN";
627
628/// Which step the override asks for, or `None` when it does not ask at all.
629///
630/// `signin` and `model` name a step, because a configured machine already has
631/// models and would otherwise always land on the model step, leaving the
632/// provider menu unreachable. Any other non-empty value that is not `0` means
633/// "open setup", letting the step be chosen the way a real first launch does.
634fn parse_first_run_override(value: &str) -> Option<SetupEntry> {
635    match value.trim().to_ascii_lowercase().as_str() {
636        "" | "0" | "false" | "no" => None,
637        "signin" | "sign-in" | "login" => Some(SetupEntry::SignIn),
638        "model" | "models" => Some(SetupEntry::ChooseModel),
639        _ => Some(SetupEntry::Auto),
640    }
641}
642
643/// The setup entry for this launch: the override when it is set, otherwise
644/// [`SetupEntry::Auto`] when Rho is about to create the config file.
645///
646/// Call this before loading the config, because loading writes the defaults.
647fn detect_first_run(repository: &ConfigRepository) -> Option<SetupEntry> {
648    if let Ok(value) = std::env::var(FIRST_RUN_OVERRIDE_VAR) {
649        if let Some(entry) = parse_first_run_override(&value) {
650            return Some(entry);
651        }
652    }
653    repository
654        .configured_path()
655        .is_ok_and(|path| !path.exists())
656        .then_some(SetupEntry::Auto)
657}
658
659fn validate_terminal_mode(cli: &Cli) -> anyhow::Result<()> {
660    if cli.command.is_none() && (!io::stdin().is_terminal() || !io::stdout().is_terminal()) {
661        anyhow::bail!(
662            "rho's default mode is the interactive TUI; use `rho run` for non-interactive automation"
663        );
664    }
665    Ok(())
666}
667
668fn is_interactive_startup_unavailable_error(error: &ModelError) -> bool {
669    matches!(
670        error,
671        ModelError::MissingCredentials(_)
672            | ModelError::Credentials(_)
673            | ModelError::UnsupportedProvider(_)
674    )
675}
676
677#[cfg(test)]
678#[path = "bootstrap_tests.rs"]
679mod tests;