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