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